diff --git a/README.md b/README.md index 1c6dc9f..3d11a32 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Python client library for interacting with the United Stated Patent and Trademark Office (USPTO) [Open Data Portal](https://data.uspto.gov/home) APIs. -This package provides clients for interacting with the USPTO Bulk Data API, Patent Data API, Final Petition Decisions API, and PTAB (Patent Trial and Appeal Board) APIs. +This package provides clients for interacting with the USPTO Bulk Data API, Patent Data API, Final Petition Decisions API, PTAB (Patent Trial and Appeal Board) APIs, Enriched Citations API, Office Action Text Retrieval API, Office Action Rejections API, and Office Action Citations API. > [!IMPORTANT] > The USPTO is in the process of moving their Developer API. This package is only concerned with the new API. The [old API](https://developer.uspto.gov/) was officially retired at the end of 2025; however, some products have not yet been fully transitioned to the Open Data Portal API. The USPTO expects the remaining products to be transitioned to the Open Data Portal in early 2026. @@ -56,6 +56,10 @@ Then use it in your Python code: ```python from pyUSPTO import ( BulkDataClient, + EnrichedCitationsClient, + OAActionsClient, + OACitationsClient, + OARejectionsClient, PatentDataClient, FinalPetitionDecisionsClient, PTABTrialsClient, @@ -73,6 +77,10 @@ petition_client = FinalPetitionDecisionsClient(config=config) trials_client = PTABTrialsClient(config=config) appeals_client = PTABAppealsClient(config=config) interferences_client = PTABInterferencesClient(config=config) +citations_client = EnrichedCitationsClient(config=config) +oa_client = OAActionsClient(config=config) +oa_citations_client = OACitationsClient(config=config) +rejections_client = OARejectionsClient(config=config) ``` ### Direct API Key @@ -235,6 +243,79 @@ print(f"Found {response.count} interference decisions since 2023") See [`examples/ptab_interferences_example.py`](examples/ptab_interferences_example.py) for detailed examples including searching by party name and outcome. +### Enriched Citations API + +```python +from pyUSPTO import EnrichedCitationsClient, USPTOConfig + +config = USPTOConfig(api_key="your_api_key_here") +client = EnrichedCitationsClient(config=config) + +# Search for enriched citations by technology center +response = client.search_citations( + tech_center_q="1700", + rows=5, +) +print(f"Found {response.count} citations from TC 1700") +``` + +See [`examples/enriched_citations_example.py`](examples/enriched_citations_example.py) for detailed examples including searching by application number and citation category. + +### Office Action Text Retrieval API + +```python +from pyUSPTO import OAActionsClient, USPTOConfig + +config = USPTOConfig(api_key="your_api_key_here") +client = OAActionsClient(config=config) + +# Search for office actions by technology center +response = client.search( + tech_center_q="1700", + rows=5, +) +print(f"Found {response.count} office actions from TC 1700") +``` + +See [`examples/oa_actions_example.py`](examples/oa_actions_example.py) for detailed examples including searching by document code and paginating results. + +### Office Action Rejections API + +```python +from pyUSPTO import OARejectionsClient, USPTOConfig + +config = USPTOConfig(api_key="your_api_key_here") +client = OARejectionsClient(config=config) + +# Search for rejections by application number +response = client.search( + patent_application_number_q="12190351", + rows=5, +) +print(f"Found {response.count} rejection records for application 12190351") +``` + +See [`examples/oa_rejections_example.py`](examples/oa_rejections_example.py) for detailed examples including searching by document code and inspecting rejection flags. + +### Office Action Citations API + +```python +from pyUSPTO import OACitationsClient, USPTOConfig + +config = USPTOConfig(api_key="your_api_key_here") +client = OACitationsClient(config=config) + +# Search for citations by legal section code +response = client.search( + legal_section_code_q="103", + tech_center_q="2800", + rows=5, +) +print(f"Found {response.count} section 103 citations in tech center 2800") +``` + +See [`examples/oa_citations_example.py`](examples/oa_citations_example.py) for detailed examples including searching by examiner-cited indicator and paginating results. + ## Documentation Full documentation may be found on [Read the Docs](https://pyuspto.readthedocs.io/). diff --git a/docs/source/api/clients/index.rst b/docs/source/api/clients/index.rst index a3cbbc5..02772b0 100644 --- a/docs/source/api/clients/index.rst +++ b/docs/source/api/clients/index.rst @@ -6,6 +6,9 @@ Clients bulk_data enriched_citations + oa_actions + oa_citations + oa_rejections patent_data petition_decisions ptab_appeals diff --git a/docs/source/api/clients/oa_actions.rst b/docs/source/api/clients/oa_actions.rst new file mode 100644 index 0000000..2948849 --- /dev/null +++ b/docs/source/api/clients/oa_actions.rst @@ -0,0 +1,7 @@ +OA Actions Client +================= + +.. automodule:: pyUSPTO.clients.oa_actions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/clients/oa_citations.rst b/docs/source/api/clients/oa_citations.rst new file mode 100644 index 0000000..c656ac7 --- /dev/null +++ b/docs/source/api/clients/oa_citations.rst @@ -0,0 +1,7 @@ +OA Citations Client +=================== + +.. automodule:: pyUSPTO.clients.oa_citations + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/clients/oa_rejections.rst b/docs/source/api/clients/oa_rejections.rst new file mode 100644 index 0000000..31995bf --- /dev/null +++ b/docs/source/api/clients/oa_rejections.rst @@ -0,0 +1,7 @@ +Office Action Rejections Client +=============================== + +.. automodule:: pyUSPTO.clients.oa_rejections + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/models/index.rst b/docs/source/api/models/index.rst index 48ec994..479df66 100644 --- a/docs/source/api/models/index.rst +++ b/docs/source/api/models/index.rst @@ -7,6 +7,9 @@ Models base bulk_data enriched_citations + oa_actions + oa_citations + oa_rejections patent_data petition_decisions ptab diff --git a/docs/source/api/models/oa_actions.rst b/docs/source/api/models/oa_actions.rst new file mode 100644 index 0000000..3245caa --- /dev/null +++ b/docs/source/api/models/oa_actions.rst @@ -0,0 +1,7 @@ +OA Actions Models +================= + +.. automodule:: pyUSPTO.models.oa_actions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/models/oa_citations.rst b/docs/source/api/models/oa_citations.rst new file mode 100644 index 0000000..9e7049c --- /dev/null +++ b/docs/source/api/models/oa_citations.rst @@ -0,0 +1,7 @@ +OA Citations Models +=================== + +.. automodule:: pyUSPTO.models.oa_citations + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/models/oa_rejections.rst b/docs/source/api/models/oa_rejections.rst new file mode 100644 index 0000000..cf36026 --- /dev/null +++ b/docs/source/api/models/oa_rejections.rst @@ -0,0 +1,7 @@ +Office Action Rejections Models +================================ + +.. automodule:: pyUSPTO.models.oa_rejections + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 0a7aa9b..101dfa9 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -6,6 +6,9 @@ Examples bulk_data enriched_citations + oa_actions + oa_citations + oa_rejections patent_data ifw_example petition_decisions diff --git a/docs/source/examples/oa_actions.rst b/docs/source/examples/oa_actions.rst new file mode 100644 index 0000000..f4f5d84 --- /dev/null +++ b/docs/source/examples/oa_actions.rst @@ -0,0 +1,6 @@ +OA Actions Example +================== + +.. literalinclude:: ../../../examples/oa_actions_example.py + :language: python + :linenos: diff --git a/docs/source/examples/oa_citations.rst b/docs/source/examples/oa_citations.rst new file mode 100644 index 0000000..c677302 --- /dev/null +++ b/docs/source/examples/oa_citations.rst @@ -0,0 +1,6 @@ +OA Citations Example +==================== + +.. literalinclude:: ../../../examples/oa_citations_example.py + :language: python + :linenos: diff --git a/docs/source/examples/oa_rejections.rst b/docs/source/examples/oa_rejections.rst new file mode 100644 index 0000000..647916a --- /dev/null +++ b/docs/source/examples/oa_rejections.rst @@ -0,0 +1,6 @@ +Office Action Rejections Examples +================================== + +.. automodule:: examples.oa_rejections_example + :members: + :undoc-members: diff --git a/examples/oa_actions_example.py b/examples/oa_actions_example.py new file mode 100644 index 0000000..083e15b --- /dev/null +++ b/examples/oa_actions_example.py @@ -0,0 +1,117 @@ +"""Example usage of pyUSPTO for Office Action Text Retrieval. + +Demonstrates the OAActionsClient for searching full-text office action +documents, filtering by various criteria, inspecting section data, +and paginating through results. +""" + +import os + +from pyUSPTO import OAActionsClient, USPTOConfig + +# --- Client Initialization --- +api_key = os.environ.get("USPTO_API_KEY", "YOUR_API_KEY_HERE") +if api_key == "YOUR_API_KEY_HERE": + raise ValueError("API key is not set. Set the USPTO_API_KEY environment variable.") +config = USPTOConfig(api_key=api_key) +client = OAActionsClient(config=config) + +print("-" * 40) +print("Example 1: Search by application number") +print("-" * 40) + +response = client.search(patent_application_number_q="11363598") +print(f"Found {response.num_found} office actions for application 11363598.") +for record in response.docs[:3]: + print(f"\n Doc Code: {record.legacy_document_code_identifier}") + print(f" Submission Date: {record.submission_date}") + print(f" Art Unit: {record.group_art_unit_number}") + if record.section: + if record.section.section_102_rejection_text: + print(" Has § 102 rejection text.") + if record.section.section_103_rejection_text: + print(" Has § 103 rejection text.") + +print("-" * 40) +print("Example 2: Search by document code (CTNF) and tech center") +print("-" * 40) + +response = client.search( + tech_center_q="2800", + legacy_document_code_identifier_q="CTNF", + rows=5, +) +print(f"Found {response.num_found} CTNF office actions in tech center 2800.") +for record in response.docs: + print( + f" App {record.patent_application_number}: " + f"AU {record.group_art_unit_number}, " + f"submitted {record.submission_date}" + ) + +print("-" * 40) +print("Example 3: Search by submission date range") +print("-" * 40) + +response = client.search( + submission_date_from_q="2010-01-01", + submission_date_to_q="2010-12-31", + rows=5, +) +print(f"Found {response.num_found} office actions submitted in 2010.") + +print("-" * 40) +print("Example 4: Search with sort") +print("-" * 40) + +response = client.search( + tech_center_q="1700", + sort="submissionDate desc", + rows=5, +) +print(f"Found {response.num_found} office actions in tech center 1700 (newest first).") +for record in response.docs: + print(f" {record.submission_date}: {record.invention_title}") + +print("-" * 40) +print("Example 5: Inspect section data") +print("-" * 40) + +response = client.search( + criteria='id:"9c27199b54dc83c9a6f643b828990d0322071461557b31ead3428885"', + rows=1, +) +if response.docs: + record = response.docs[0] + print(f"Record: {record.id}") + print(f" Patent Number: {record.patent_number}") + print(f" Invention Title: {record.invention_title}") + if record.section: + print(" Section 102 text (first 200 chars):") + for text in record.section.section_102_rejection_text: + if text: + print(f" {text[:200]}...") + +print("-" * 40) +print("Example 6: Paginate through results") +print("-" * 40) + +max_items = 30 +count = 0 +for _ in client.paginate(tech_center_q="1700", rows=10): + count += 1 + if count >= max_items: + print(f" ... (stopping at {max_items} items)") + break + +print(f"Retrieved {count} office action records via pagination.") + +print("-" * 40) +print("Example 7: Get available fields") +print("-" * 40) + +fields_response = client.get_fields() +print(f"API Status: {fields_response.api_status}") +print(f"Field Count: {fields_response.field_count}") +print(f"Last Updated: {fields_response.last_data_updated_date}") +print(f"Sample fields: {fields_response.fields[:5]}") diff --git a/examples/oa_citations_example.py b/examples/oa_citations_example.py new file mode 100644 index 0000000..6d4dc64 --- /dev/null +++ b/examples/oa_citations_example.py @@ -0,0 +1,106 @@ +"""Example usage of pyUSPTO for Office Action Citations. + +Demonstrates the OACitationsClient for searching citation data from +Office Actions, filtering by various criteria, and paginating through results. +""" + +import os + +from pyUSPTO import OACitationsClient, USPTOConfig + +# --- Client Initialization --- +api_key = os.environ.get("USPTO_API_KEY", "YOUR_API_KEY_HERE") +if api_key == "YOUR_API_KEY_HERE": + raise ValueError("API key is not set. Set the USPTO_API_KEY environment variable.") +config = USPTOConfig(api_key=api_key) +client = OACitationsClient(config=config) + +print("-" * 40) +print("Example 1: Search by application number") +print("-" * 40) + +response = client.search(patent_application_number_q="17519936") +print(f"Found {response.num_found} citations for application 17519936.") +for record in response.docs[:3]: + print(f"\n Legal Section: {record.legal_section_code}") + print(f" Action Type: {record.action_type_category}") + print(f" Reference: {record.reference_identifier}") + print(f" Examiner Cited: {record.examiner_cited_reference_indicator}") + +print("-" * 40) +print("Example 2: Search by legal section code and tech center") +print("-" * 40) + +response = client.search( + tech_center_q="2800", + legal_section_code_q="103", + rows=5, +) +print(f"Found {response.num_found} section 103 citations in tech center 2800.") +for record in response.docs: + print( + f" App {record.patent_application_number}: " + f"AU {record.group_art_unit_number}, " + f"ref {record.parsed_reference_identifier}" + ) + +print("-" * 40) +print("Example 3: Search by examiner-cited indicator") +print("-" * 40) + +response = client.search( + examiner_cited_reference_indicator_q=True, + tech_center_q="1700", + rows=5, +) +print(f"Found {response.num_found} examiner-cited references in tech center 1700.") +for record in response.docs: + print(f" {record.reference_identifier}") + +print("-" * 40) +print("Example 4: Search by create date range") +print("-" * 40) + +response = client.search( + create_date_time_from_q="2025-07-01", + create_date_time_to_q="2025-07-04", + rows=5, +) +print(f"Found {response.num_found} citations created 2025-07-01 to 2025-07-04.") + +print("-" * 40) +print("Example 5: Search with sort") +print("-" * 40) + +response = client.search( + tech_center_q="2800", + sort="createDateTime desc", + rows=5, +) +print(f"Found {response.num_found} citations in tech center 2800 (newest first).") +for record in response.docs: + print(f" {record.create_date_time}: {record.patent_application_number}") + +print("-" * 40) +print("Example 6: Paginate through results") +print("-" * 40) + +max_items = 30 +count = 0 +for _ in client.paginate(tech_center_q="2800", rows=10): + count += 1 + if count >= max_items: + print(f" ... (stopping at {max_items} items)") + break + +print(f"Retrieved {count} citation records via pagination.") + +print("-" * 40) +print("Example 7: Get available fields") +print("-" * 40) + +fields_response = client.get_fields() +print(f"API Status: {fields_response.api_status}") +print(f"Field Count: {fields_response.field_count}") +print(f"Last Updated: {fields_response.last_data_updated_date}") +print(f"Sample fields: {fields_response.fields[:5]}") diff --git a/examples/oa_rejections_example.py b/examples/oa_rejections_example.py new file mode 100644 index 0000000..4bbec58 --- /dev/null +++ b/examples/oa_rejections_example.py @@ -0,0 +1,63 @@ +"""Examples for the OARejectionsClient. + +This module demonstrates how to use the OARejectionsClient to search for +rejection-level data from USPTO Office Actions. +""" + +from pyUSPTO import OARejectionsClient, USPTOConfig + +config = USPTOConfig.from_env() +client = OARejectionsClient(config=config) + +# --- Example 1: Search by application number --- +response = client.search(patent_application_number_q="12190351") +print(f"Found {response.count} records for application 12190351") +for record in response.docs: + print(f" {record.id}: {record.legacy_document_code_identifier}") + +# --- Example 2: Search by document code and date range --- +response = client.search( + legacy_document_code_identifier_q="CTNF", + submission_date_from_q="2020-01-01", + submission_date_to_q="2020-12-31", + rows=5, +) +print(f"\nFound {response.count} CTNF rejections in 2020") + +# --- Example 3: Search with a direct criteria string --- +response = client.search( + criteria="hasRej103:1 AND groupArtUnitNumber:1713", + rows=5, +) +print(f"\nFound {response.count} 103-rejection records in art unit 1713") + +# --- Example 4: Inspect rejection flags --- +response = client.search(patent_application_number_q="12190351", rows=1) +if response.docs: + record = response.docs[0] + print(f"\nRecord: {record.id}") + print(f" 101 rejection: {record.has_rej_101}") + print(f" 102 rejection: {record.has_rej_102}") + print(f" 103 rejection: {record.has_rej_103}") + print(f" 112 rejection: {record.has_rej_112}") + print(f" Alice indicator: {record.alice_indicator}") + print(f" Claims: {record.claim_number_array_document}") + +# --- Example 5: Search with POST body --- +response = client.search( + post_body={"criteria": "patentApplicationNumber:12190351", "rows": 10} +) +print(f"\nFound {response.count} records via POST body") + +# --- Example 6: Paginate through results --- +count = 0 +for _ in client.paginate( + legal_section_code_q="103", + submission_date_from_q="2020-01-01", + submission_date_to_q="2020-01-31", + rows=25, +): + count += 1 + if count >= 50: + break +print(f"\nIterated through {count} records") diff --git a/src/pyUSPTO/__init__.py b/src/pyUSPTO/__init__.py index 14a5608..269dacd 100644 --- a/src/pyUSPTO/__init__.py +++ b/src/pyUSPTO/__init__.py @@ -13,6 +13,9 @@ from pyUSPTO.clients.bulk_data import BulkDataClient from pyUSPTO.clients.enriched_citations import EnrichedCitationsClient +from pyUSPTO.clients.oa_actions import OAActionsClient +from pyUSPTO.clients.oa_citations import OACitationsClient +from pyUSPTO.clients.oa_rejections import OARejectionsClient from pyUSPTO.clients.patent_data import PatentDataClient from pyUSPTO.clients.petition_decisions import FinalPetitionDecisionsClient from pyUSPTO.clients.ptab_appeals import PTABAppealsClient @@ -35,14 +38,30 @@ FileData, ProductFileBag, ) - -# Import model implementations from pyUSPTO.models.enriched_citations import ( CitationCategoryCode, EnrichedCitation, EnrichedCitationFieldsResponse, EnrichedCitationResponse, ) + +# Import model implementations +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, + OAActionsSection, +) +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) from pyUSPTO.models.patent_data import ( ApplicationContinuityData, PatentDataResponse, @@ -89,6 +108,22 @@ "USPTOTimezoneWarning", "USPTOEnumParseWarning", "USPTODataMismatchWarning", + # OA Actions API + "OAActionsClient", + "OAActionsRecord", + "OAActionsResponse", + "OAActionsSection", + "OAActionsFieldsResponse", + # OA Citations API + "OACitationsClient", + "OACitationRecord", + "OACitationsResponse", + "OACitationsFieldsResponse", + # OA Rejections API + "OARejectionsClient", + "OARejectionsRecord", + "OARejectionsResponse", + "OARejectionsFieldsResponse", # Enriched Citations API "EnrichedCitationsClient", "CitationCategoryCode", diff --git a/src/pyUSPTO/clients/__init__.py b/src/pyUSPTO/clients/__init__.py index dc2810d..8fec09b 100644 --- a/src/pyUSPTO/clients/__init__.py +++ b/src/pyUSPTO/clients/__init__.py @@ -5,6 +5,9 @@ from pyUSPTO.clients.bulk_data import BulkDataClient from pyUSPTO.clients.enriched_citations import EnrichedCitationsClient +from pyUSPTO.clients.oa_actions import OAActionsClient +from pyUSPTO.clients.oa_citations import OACitationsClient +from pyUSPTO.clients.oa_rejections import OARejectionsClient from pyUSPTO.clients.patent_data import PatentDataClient from pyUSPTO.clients.petition_decisions import FinalPetitionDecisionsClient from pyUSPTO.clients.ptab_appeals import PTABAppealsClient @@ -14,6 +17,9 @@ __all__ = [ "BulkDataClient", "EnrichedCitationsClient", + "OAActionsClient", + "OACitationsClient", + "OARejectionsClient", "PatentDataClient", "FinalPetitionDecisionsClient", "PTABTrialsClient", diff --git a/src/pyUSPTO/clients/base.py b/src/pyUSPTO/clients/base.py index 654f34b..76ba16f 100644 --- a/src/pyUSPTO/clients/base.py +++ b/src/pyUSPTO/clients/base.py @@ -15,6 +15,9 @@ ) from pyUSPTO.models.enriched_citations import EnrichedCitationResponse +from pyUSPTO.models.oa_actions import OAActionsResponse +from pyUSPTO.models.oa_citations import OACitationsResponse +from pyUSPTO.models.oa_rejections import OARejectionsResponse try: from typing import Self @@ -357,8 +360,12 @@ def _get_model( endpoint, custom_url=custom_url, custom_base_url=custom_base_url ) - if response_class == EnrichedCitationResponse: - # Handling for EnrichedCitationResponse to support form-urlencoded POST requests + if ( + response_class == EnrichedCitationResponse + or response_class == OAActionsResponse + or response_class == OACitationsResponse + or response_class == OARejectionsResponse + ): response = self._execute_request( method=method, url=url, diff --git a/src/pyUSPTO/clients/oa_actions.py b/src/pyUSPTO/clients/oa_actions.py new file mode 100644 index 0000000..5d39265 --- /dev/null +++ b/src/pyUSPTO/clients/oa_actions.py @@ -0,0 +1,238 @@ +"""clients.oa_actions - Client for USPTO Office Action Text Retrieval API. + +This module provides a client for interacting with the USPTO Office Action +Text Retrieval API (v1). It allows users to search for full-text office action +documents issued during patent examination, including body text and structured +section data for rejections and allowances. +""" + +from collections.abc import Iterator +from typing import Any + +from pyUSPTO.clients.base import BaseUSPTOClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, +) + + +class OAActionsClient(BaseUSPTOClient[OAActionsResponse]): + """Client for interacting with the USPTO Office Action Text Retrieval API. + + This client provides methods to search for full-text office action documents. + The API refreshes daily and contains publicly available Office Actions starting + with 12 series applications. + """ + + ENDPOINTS = { + "search": "api/v1/patent/oa/oa_actions/v1/records", + "get_fields": "api/v1/patent/oa/oa_actions/v1/fields", + } + + def __init__( + self, + config: USPTOConfig | None = None, + base_url: str | None = None, + ): + """Initialize the OAActionsClient. + + Args: + config: USPTOConfig instance containing API key and settings. If not provided, + creates config from environment variables (requires USPTO_API_KEY). + base_url: Optional base URL override for the USPTO OA Actions API. + If not provided, uses config.oa_actions_base_url or default. + """ + if config is None: + self.config = USPTOConfig.from_env() + else: + self.config = config + + effective_base_url = base_url or self.config.oa_actions_base_url + + super().__init__(base_url=effective_base_url, config=self.config) + + def search( + self, + criteria: str | None = None, + sort: str | None = None, + start: int | None = 0, + rows: int | None = 25, + post_body: dict[str, Any] | None = None, + # Convenience query parameters + patent_application_number_q: str | None = None, + legacy_document_code_identifier_q: str | None = None, + group_art_unit_number_q: str | int | None = None, + tech_center_q: str | None = None, + access_level_category_q: str | None = None, + application_type_category_q: str | None = None, + submission_date_from_q: str | None = None, + submission_date_to_q: str | None = None, + additional_query_params: dict[str, Any] | None = None, + ) -> OAActionsResponse: + """Return office action records matching the given criteria. + + This method performs a POST request (form-urlencoded) to search for + office action documents. You can provide either a direct post_body, + a criteria string, or use convenience parameters. + + Args: + criteria: Direct Solr query string (e.g., ``"patentApplicationNumber:14485382"``). + sort: Sort order for results (e.g., ``"submissionDate desc"``). + start: Starting index for pagination (default: 0). + rows: Maximum number of records to return (default: 25). + post_body: Optional POST body dict for complex queries. When provided, + all other parameters are ignored. + patent_application_number_q: Filter by patent application number. + legacy_document_code_identifier_q: Filter by document code (e.g., ``"CTNF"``, ``"NOA"``). + group_art_unit_number_q: Filter by group art unit number. + tech_center_q: Filter by technology center code. + access_level_category_q: Filter by access level (e.g., ``"PUBLIC"``). + application_type_category_q: Filter by application type (e.g., ``"REGULAR"``). + submission_date_from_q: Filter from this submission date (``"YYYY-MM-DD"``). + submission_date_to_q: Filter to this submission date (``"YYYY-MM-DD"``). + additional_query_params: Additional custom POST body parameters. + + Returns: + OAActionsResponse: Response containing matching office action records. + + Examples: + # Search with a direct criteria string + >>> response = client.search( + ... criteria="patentApplicationNumber:14485382" + ... ) + + # Search with convenience parameters + >>> response = client.search( + ... tech_center_q="1700", + ... legacy_document_code_identifier_q="CTNF", + ... rows=50, + ... ) + + # Search with POST body + >>> response = client.search( + ... post_body={"criteria": "techCenter:1700", "rows": 100} + ... ) + """ + endpoint = self.ENDPOINTS["search"] + + if post_body is not None: + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OAActionsResponse, + json_data=post_body, + params=additional_query_params, + ) + + # Build POST body from parameters + body: dict[str, Any] = {} + + # Build criteria from convenience parameters + final_criteria = criteria + if final_criteria is None: + q_parts = [] + if patent_application_number_q: + q_parts.append(f"patentApplicationNumber:{patent_application_number_q}") + if legacy_document_code_identifier_q: + q_parts.append( + f"legacyDocumentCodeIdentifier:{legacy_document_code_identifier_q}" + ) + if group_art_unit_number_q is not None: + q_parts.append(f"groupArtUnitNumber:{group_art_unit_number_q}") + if tech_center_q: + q_parts.append(f"techCenter:{tech_center_q}") + if access_level_category_q: + q_parts.append(f"accessLevelCategory:{access_level_category_q}") + if application_type_category_q: + q_parts.append(f"applicationTypeCategory:{application_type_category_q}") + + if submission_date_from_q and submission_date_to_q: + q_parts.append( + f"submissionDate:[{submission_date_from_q} TO {submission_date_to_q}]" + ) + elif submission_date_from_q: + q_parts.append(f"submissionDate:>={submission_date_from_q}") + elif submission_date_to_q: + q_parts.append(f"submissionDate:<={submission_date_to_q}") + + if q_parts: + final_criteria = " AND ".join(q_parts) + + if final_criteria is not None: + body["criteria"] = final_criteria + if sort is not None: + body["sort"] = sort + if start is not None: + body["start"] = start + if rows is not None: + body["rows"] = rows + + if additional_query_params: + body.update(additional_query_params) + + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OAActionsResponse, + json_data=body, + ) + + def get_fields(self) -> OAActionsFieldsResponse: + """Retrieve available fields and API metadata for the OA Actions API. + + Returns: + OAActionsFieldsResponse: API metadata including available field + names and last data update timestamp. + + Examples: + >>> fields_response = client.get_fields() + >>> print(fields_response.field_count) + 56 + >>> print(fields_response.api_status) + 'PUBLISHED' + """ + endpoint = self.ENDPOINTS["get_fields"] + return self._get_model( + method="GET", + endpoint=endpoint, + response_class=OAActionsFieldsResponse, + ) + + def paginate( + self, post_body: dict[str, Any] | None = None, **kwargs: Any + ) -> Iterator[OAActionsRecord]: + """Provide an iterator to paginate through office action search results. + + Automatically handles pagination using Solr-style start/rows parameters. + The ``start`` parameter is managed internally; providing it will raise a ValueError. + + Args: + post_body: Optional POST body dict for complex search queries. + **kwargs: Keyword arguments passed to :meth:`search`. + + Returns: + Iterator[OAActionsRecord]: An iterator yielding OAActionsRecord objects. + + Examples: + # Paginate through all CTNF actions in tech center 1700 + >>> for record in client.paginate( + ... tech_center_q="1700", + ... legacy_document_code_identifier_q="CTNF", + ... rows=50, + ... ): + ... print(record.patent_application_number) + + # Paginate with POST body + >>> for record in client.paginate( + ... post_body={"criteria": "techCenter:1700", "rows": 50} + ... ): + ... process(record) + """ + return self.paginate_solr_results( + method_name="search", + response_container_attr="docs", + post_body=post_body, + **kwargs, + ) diff --git a/src/pyUSPTO/clients/oa_citations.py b/src/pyUSPTO/clients/oa_citations.py new file mode 100644 index 0000000..66a31d2 --- /dev/null +++ b/src/pyUSPTO/clients/oa_citations.py @@ -0,0 +1,247 @@ +"""clients.oa_citations - Client for USPTO Office Action Citations API. + +This module provides a client for interacting with the USPTO Office Action +Citations API (v2). It allows users to search citation data from Office Actions +mailed from October 1, 2017 to 30 days prior to the current date, derived from +Form PTO-892, Form PTO-1449, and Office Action text. +""" + +from collections.abc import Iterator +from typing import Any + +from pyUSPTO.clients.base import BaseUSPTOClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) + + +class OACitationsClient(BaseUSPTOClient[OACitationsResponse]): + """Client for interacting with the USPTO Office Action Citations API. + + This client provides methods to search for citation data referenced in + Office Actions. The API refreshes daily and uses information derived from + citations on Form PTO-892, Form PTO-1449, and Office Action text. + """ + + ENDPOINTS = { + "search": "api/v1/patent/oa/oa_citations/v2/records", + "get_fields": "api/v1/patent/oa/oa_citations/v2/fields", + } + + def __init__( + self, + config: USPTOConfig | None = None, + base_url: str | None = None, + ): + """Initialize the OACitationsClient. + + Args: + config: USPTOConfig instance containing API key and settings. If not provided, + creates config from environment variables (requires USPTO_API_KEY). + base_url: Optional base URL override for the USPTO OA Citations API. + If not provided, uses config.oa_citations_base_url or default. + """ + if config is None: + self.config = USPTOConfig.from_env() + else: + self.config = config + + effective_base_url = base_url or self.config.oa_citations_base_url + + super().__init__(base_url=effective_base_url, config=self.config) + + def search( + self, + criteria: str | None = None, + sort: str | None = None, + start: int | None = 0, + rows: int | None = 25, + post_body: dict[str, Any] | None = None, + # Convenience query parameters + patent_application_number_q: str | None = None, + legal_section_code_q: str | None = None, + action_type_category_q: str | None = None, + tech_center_q: str | None = None, + work_group_q: str | None = None, + group_art_unit_number_q: str | None = None, + examiner_cited_reference_indicator_q: bool | None = None, + applicant_cited_examiner_reference_indicator_q: bool | None = None, + create_date_time_from_q: str | None = None, + create_date_time_to_q: str | None = None, + additional_query_params: dict[str, Any] | None = None, + ) -> OACitationsResponse: + """Return citation records matching the given criteria. + + This method performs a POST request (form-urlencoded) to search for + Office Action citation records. You can provide either a direct post_body, + a criteria string, or use convenience parameters. + + Args: + criteria: Direct Solr query string (e.g., ``"patentApplicationNumber:16845502"``). + sort: Sort order for results (e.g., ``"createDateTime desc"``). + start: Starting index for pagination (default: 0). + rows: Maximum number of records to return (default: 25). + post_body: Optional POST body dict for complex queries. When provided, + all other parameters are ignored. + patent_application_number_q: Filter by patent application number. + legal_section_code_q: Filter by legal section code (e.g., ``"101"``, ``"103"``). + action_type_category_q: Filter by action type (e.g., ``"rejected"``). + tech_center_q: Filter by technology center code. + work_group_q: Filter by work group code. + group_art_unit_number_q: Filter by group art unit number. + examiner_cited_reference_indicator_q: Filter by examiner-cited indicator. + applicant_cited_examiner_reference_indicator_q: Filter by applicant-cited + examiner reference indicator. + create_date_time_from_q: Filter from this create date (``"YYYY-MM-DD"``). + create_date_time_to_q: Filter to this create date (``"YYYY-MM-DD"``). + additional_query_params: Additional custom POST body parameters. + + Returns: + OACitationsResponse: Response containing matching citation records. + + Examples: + # Search with a direct criteria string + >>> response = client.search( + ... criteria="patentApplicationNumber:16845502" + ... ) + + # Search with convenience parameters + >>> response = client.search( + ... legal_section_code_q="103", + ... examiner_cited_reference_indicator_q=True, + ... rows=50, + ... ) + + # Search with POST body + >>> response = client.search( + ... post_body={"criteria": "techCenter:2800", "rows": 100} + ... ) + """ + endpoint = self.ENDPOINTS["search"] + + if post_body is not None: + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OACitationsResponse, + json_data=post_body, + params=additional_query_params, + ) + + # Build POST body from parameters + body: dict[str, Any] = {} + + # Build criteria from convenience parameters + final_criteria = criteria + if final_criteria is None: + q_parts = [] + if patent_application_number_q: + q_parts.append(f"patentApplicationNumber:{patent_application_number_q}") + if legal_section_code_q: + q_parts.append(f"legalSectionCode:*{legal_section_code_q}*") + if action_type_category_q: + q_parts.append(f"actionTypeCategory:{action_type_category_q}") + if tech_center_q: + q_parts.append(f"techCenter:{tech_center_q}") + if work_group_q: + q_parts.append(f"workGroup:{work_group_q}") + if group_art_unit_number_q: + q_parts.append(f"groupArtUnitNumber:{group_art_unit_number_q}") + if examiner_cited_reference_indicator_q is not None: + val = str(examiner_cited_reference_indicator_q).lower() + q_parts.append(f"examinerCitedReferenceIndicator:{val}") + if applicant_cited_examiner_reference_indicator_q is not None: + val = str(applicant_cited_examiner_reference_indicator_q).lower() + q_parts.append(f"applicantCitedExaminerReferenceIndicator:{val}") + + if create_date_time_from_q and create_date_time_to_q: + q_parts.append( + f"createDateTime:[{create_date_time_from_q} TO {create_date_time_to_q}]" + ) + elif create_date_time_from_q: + q_parts.append(f"createDateTime:>={create_date_time_from_q}") + elif create_date_time_to_q: + q_parts.append(f"createDateTime:<={create_date_time_to_q}") + + if q_parts: + final_criteria = " AND ".join(q_parts) + + if final_criteria is not None: + body["criteria"] = final_criteria + if sort is not None: + body["sort"] = sort + if start is not None: + body["start"] = start + if rows is not None: + body["rows"] = rows + + if additional_query_params: + body.update(additional_query_params) + + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OACitationsResponse, + json_data=body, + ) + + def get_fields(self) -> OACitationsFieldsResponse: + """Retrieve available fields and API metadata for the OA Citations API. + + Returns: + OACitationsFieldsResponse: API metadata including available field + names and last data update timestamp. + + Examples: + >>> fields_response = client.get_fields() + >>> print(fields_response.field_count) + 16 + >>> print(fields_response.api_status) + 'PUBLISHED' + """ + endpoint = self.ENDPOINTS["get_fields"] + return self._get_model( + method="GET", + endpoint=endpoint, + response_class=OACitationsFieldsResponse, + ) + + def paginate( + self, post_body: dict[str, Any] | None = None, **kwargs: Any + ) -> Iterator[OACitationRecord]: + """Provide an iterator to paginate through citation search results. + + Automatically handles pagination using Solr-style start/rows parameters. + The ``start`` parameter is managed internally; providing it will raise a ValueError. + + Args: + post_body: Optional POST body dict for complex search queries. + **kwargs: Keyword arguments passed to :meth:`search`. + + Returns: + Iterator[OACitationRecord]: An iterator yielding OACitationRecord objects. + + Examples: + # Paginate through all 103 rejections with examiner-cited references + >>> for record in client.paginate( + ... legal_section_code_q="103", + ... examiner_cited_reference_indicator_q=True, + ... rows=50, + ... ): + ... print(record.parsed_reference_identifier) + + # Paginate with POST body + >>> for record in client.paginate( + ... post_body={"criteria": "techCenter:2800", "rows": 50} + ... ): + ... process(record) + """ + return self.paginate_solr_results( + method_name="search", + response_container_attr="docs", + post_body=post_body, + **kwargs, + ) diff --git a/src/pyUSPTO/clients/oa_rejections.py b/src/pyUSPTO/clients/oa_rejections.py new file mode 100644 index 0000000..e2cfca3 --- /dev/null +++ b/src/pyUSPTO/clients/oa_rejections.py @@ -0,0 +1,233 @@ +"""clients.oa_rejections - Client for USPTO Office Action Rejections API. + +This module provides a client for interacting with the USPTO Office Action +Rejections API (v2). It allows users to search for rejection-level data from +Office Actions mailed from October 1, 2017 to 30 days prior to the current +date, including rejection type indicators, claim arrays, and examiner metadata. +""" + +from collections.abc import Iterator +from typing import Any + +from pyUSPTO.clients.base import BaseUSPTOClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) + + +class OARejectionsClient(BaseUSPTOClient[OARejectionsResponse]): + """Client for interacting with the USPTO Office Action Rejections API. + + This client provides methods to search for rejection data from Office + Actions. The API refreshes daily and contains publicly available data + starting with 12 series applications. + """ + + ENDPOINTS = { + "search": "api/v1/patent/oa/oa_rejections/v2/records", + "get_fields": "api/v1/patent/oa/oa_rejections/v2/fields", + } + + def __init__( + self, + config: USPTOConfig | None = None, + base_url: str | None = None, + ): + """Initialize the OARejectionsClient. + + Args: + config: USPTOConfig instance containing API key and settings. If not provided, + creates config from environment variables (requires USPTO_API_KEY). + base_url: Optional base URL override for the USPTO OA Rejections API. + If not provided, uses config.oa_rejections_base_url or default. + """ + if config is None: + self.config = USPTOConfig.from_env() + else: + self.config = config + + effective_base_url = base_url or self.config.oa_rejections_base_url + + super().__init__(base_url=effective_base_url, config=self.config) + + def search( + self, + criteria: str | None = None, + sort: str | None = None, + start: int | None = 0, + rows: int | None = 25, + post_body: dict[str, Any] | None = None, + # Convenience query parameters + patent_application_number_q: str | None = None, + legacy_document_code_identifier_q: str | None = None, + group_art_unit_number_q: str | None = None, + legal_section_code_q: str | None = None, + action_type_category_q: str | None = None, + submission_date_from_q: str | None = None, + submission_date_to_q: str | None = None, + additional_query_params: dict[str, Any] | None = None, + ) -> OARejectionsResponse: + """Return rejection records matching the given criteria. + + This method performs a POST request (form-urlencoded) to search for + office action rejection records. You can provide either a direct + post_body, a criteria string, or use convenience parameters. + + Args: + criteria: Direct Solr query string (e.g., ``"patentApplicationNumber:12190351"``). + sort: Sort order for results (e.g., ``"submissionDate desc"``). + start: Starting index for pagination (default: 0). + rows: Maximum number of records to return (default: 25). + post_body: Optional POST body dict for complex queries. When provided, + all other parameters are ignored. + patent_application_number_q: Filter by patent application number. + legacy_document_code_identifier_q: Filter by document code (e.g., ``"CTNF"``). + group_art_unit_number_q: Filter by group art unit number. + legal_section_code_q: Filter by legal section code. + action_type_category_q: Filter by action type category. + submission_date_from_q: Filter from this submission date (``"YYYY-MM-DD"``). + submission_date_to_q: Filter to this submission date (``"YYYY-MM-DD"``). + additional_query_params: Additional custom POST body parameters. + + Returns: + OARejectionsResponse: Response containing matching rejection records. + + Examples: + # Search with a direct criteria string + >>> response = client.search( + ... criteria="patentApplicationNumber:12190351" + ... ) + + # Search with convenience parameters + >>> response = client.search( + ... legacy_document_code_identifier_q="CTNF", + ... submission_date_from_q="2020-01-01", + ... rows=50, + ... ) + + # Search with POST body + >>> response = client.search( + ... post_body={"criteria": "hasRej103:1", "rows": 100} + ... ) + """ + endpoint = self.ENDPOINTS["search"] + + if post_body is not None: + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OARejectionsResponse, + json_data=post_body, + params=additional_query_params, + ) + + # Build POST body from parameters + body: dict[str, Any] = {} + + # Build criteria from convenience parameters + final_criteria = criteria + if final_criteria is None: + q_parts = [] + if patent_application_number_q: + q_parts.append(f"patentApplicationNumber:{patent_application_number_q}") + if legacy_document_code_identifier_q: + q_parts.append( + f"legacyDocumentCodeIdentifier:{legacy_document_code_identifier_q}" + ) + if group_art_unit_number_q: + q_parts.append(f"groupArtUnitNumber:{group_art_unit_number_q}") + if legal_section_code_q: + q_parts.append(f"legalSectionCode:{legal_section_code_q}") + if action_type_category_q: + q_parts.append(f"actionTypeCategory:{action_type_category_q}") + + if submission_date_from_q and submission_date_to_q: + q_parts.append( + f"submissionDate:[{submission_date_from_q} TO {submission_date_to_q}]" + ) + elif submission_date_from_q: + q_parts.append(f"submissionDate:>={submission_date_from_q}") + elif submission_date_to_q: + q_parts.append(f"submissionDate:<={submission_date_to_q}") + + if q_parts: + final_criteria = " AND ".join(q_parts) + + if final_criteria is not None: + body["criteria"] = final_criteria + if sort is not None: + body["sort"] = sort + if start is not None: + body["start"] = start + if rows is not None: + body["rows"] = rows + + if additional_query_params: + body.update(additional_query_params) + + return self._get_model( + method="POST", + endpoint=endpoint, + response_class=OARejectionsResponse, + json_data=body, + ) + + def get_fields(self) -> OARejectionsFieldsResponse: + """Retrieve available fields and API metadata for the OA Rejections API. + + Returns: + OARejectionsFieldsResponse: API metadata including available field + names and last data update timestamp. + + Examples: + >>> fields_response = client.get_fields() + >>> print(fields_response.field_count) + 31 + >>> print(fields_response.api_status) + 'PUBLISHED' + """ + endpoint = self.ENDPOINTS["get_fields"] + return self._get_model( + method="GET", + endpoint=endpoint, + response_class=OARejectionsFieldsResponse, + ) + + def paginate( + self, post_body: dict[str, Any] | None = None, **kwargs: Any + ) -> Iterator[OARejectionsRecord]: + """Provide an iterator to paginate through rejection search results. + + Automatically handles pagination using Solr-style start/rows parameters. + The ``start`` parameter is managed internally; providing it will raise a ValueError. + + Args: + post_body: Optional POST body dict for complex search queries. + **kwargs: Keyword arguments passed to :meth:`search`. + + Returns: + Iterator[OARejectionsRecord]: An iterator yielding OARejectionsRecord objects. + + Examples: + # Paginate through all CTNF rejections + >>> for record in client.paginate( + ... legacy_document_code_identifier_q="CTNF", + ... rows=50, + ... ): + ... print(record.patent_application_number) + + # Paginate with POST body + >>> for record in client.paginate( + ... post_body={"criteria": "hasRej103:1", "rows": 50} + ... ): + ... process(record) + """ + return self.paginate_solr_results( + method_name="search", + response_container_attr="docs", + post_body=post_body, + **kwargs, + ) diff --git a/src/pyUSPTO/config.py b/src/pyUSPTO/config.py index 3ec4040..bbed316 100644 --- a/src/pyUSPTO/config.py +++ b/src/pyUSPTO/config.py @@ -30,6 +30,9 @@ def __init__( petition_decisions_base_url: str = DEFAULT_BASE_URL, ptab_base_url: str = DEFAULT_BASE_URL, enriched_citations_base_url: str = DEFAULT_BASE_URL, + oa_actions_base_url: str = DEFAULT_BASE_URL, + oa_rejections_base_url: str = DEFAULT_BASE_URL, + oa_citations_base_url: str = DEFAULT_BASE_URL, http_config: HTTPConfig | None = None, include_raw_data: bool = False, ): @@ -42,6 +45,9 @@ def __init__( petition_decisions_base_url: Base URL for the Final Petition Decisions API ptab_base_url: Base URL for the PTAB (Patent Trial and Appeal Board) API enriched_citations_base_url: Base URL for the Enriched Citations API + oa_actions_base_url: Base URL for the Office Action Text Retrieval API + oa_rejections_base_url: Base URL for the Office Action Rejections API + oa_citations_base_url: Base URL for the Office Action Citations API http_config: Optional HTTPConfig for request handling (uses defaults if None) include_raw_data: If True, store raw JSON in response objects for debugging (default: False) """ @@ -54,6 +60,9 @@ def __init__( self.petition_decisions_base_url = petition_decisions_base_url self.ptab_base_url = ptab_base_url self.enriched_citations_base_url = enriched_citations_base_url + self.oa_actions_base_url = oa_actions_base_url + self.oa_rejections_base_url = oa_rejections_base_url + self.oa_citations_base_url = oa_citations_base_url # Use provided HTTPConfig or create default self.http_config = http_config if http_config is not None else HTTPConfig() @@ -86,6 +95,15 @@ def from_env(cls) -> "USPTOConfig": enriched_citations_base_url=os.environ.get( "USPTO_ENRICHED_CITATIONS_BASE_URL", DEFAULT_BASE_URL ), + oa_actions_base_url=os.environ.get( + "USPTO_OA_ACTIONS_BASE_URL", DEFAULT_BASE_URL + ), + oa_rejections_base_url=os.environ.get( + "USPTO_OA_REJECTIONS_BASE_URL", DEFAULT_BASE_URL + ), + oa_citations_base_url=os.environ.get( + "USPTO_OA_CITATIONS_BASE_URL", DEFAULT_BASE_URL + ), # Also read HTTP config from environment http_config=HTTPConfig.from_env(), ) diff --git a/src/pyUSPTO/models/__init__.py b/src/pyUSPTO/models/__init__.py index aef6888..8785b57 100644 --- a/src/pyUSPTO/models/__init__.py +++ b/src/pyUSPTO/models/__init__.py @@ -16,6 +16,22 @@ EnrichedCitationFieldsResponse, EnrichedCitationResponse, ) +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, + OAActionsSection, +) +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) from pyUSPTO.models.petition_decisions import ( DocumentDownloadOption, PetitionDecision, @@ -36,6 +52,19 @@ "FromDictProtocol", # Enriched Citations Models "CitationCategoryCode", + # OA Actions Models + "OAActionsRecord", + "OAActionsResponse", + "OAActionsSection", + "OAActionsFieldsResponse", + # OA Citations Models + "OACitationRecord", + "OACitationsResponse", + "OACitationsFieldsResponse", + # OA Rejections Models + "OARejectionsRecord", + "OARejectionsResponse", + "OARejectionsFieldsResponse", "EnrichedCitation", "EnrichedCitationResponse", "EnrichedCitationFieldsResponse", diff --git a/src/pyUSPTO/models/oa_actions.py b/src/pyUSPTO/models/oa_actions.py new file mode 100644 index 0000000..d94efba --- /dev/null +++ b/src/pyUSPTO/models/oa_actions.py @@ -0,0 +1,570 @@ +"""models.oa_actions - Data models for USPTO Office Action Text Retrieval API. + +This module provides data models for representing responses from the USPTO +Office Action Text Retrieval API (v1). These models cover office action +documents including full body text and structured section data. +""" + +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from pyUSPTO.models.utils import parse_to_datetime_utc, serialize_datetime_as_naive + + +@dataclass(frozen=True) +class OAActionsSection: + """Structured section data extracted from an Office Action document. + + These fields are returned as flat ``sections.*`` keys alongside the + top-level record fields in the API response. + + Attributes: + section_101_rejection_text: Full text of the 35 U.S.C. § 101 rejection. + grant_date: Grant date associated with this section. + filing_date: Filing date associated with this section. + submission_date: Submission date of this section. + examiner_employee_number: Examiner employee number(s). + section_103_rejection_text: Full text of the 35 U.S.C. § 103 rejection(s). + specification_title_text: Title of the specification. + detail_citation_text: Detailed citation text. + national_subclass: National subclass code(s). + tech_center_number: Technology center number(s). + patent_application_number: Patent application number(s). + national_class: National class code(s). + work_group_number: Work group number(s). + terminal_disclaimer_status_text: Terminal disclaimer status text. + group_art_unit_number: Group art unit number(s). + proceeding_appendix_text: Proceeding appendix text. + office_action_identifier: Office action identifier(s). + withdrawal_rejection_text: Withdrawal rejection text. + obsolete_document_identifier: Legacy IFW document identifier(s). + section_102_rejection_text: Full text of the 35 U.S.C. § 102 rejection(s). + legacy_document_code_identifier: Legacy document code identifier(s). + section_112_rejection_text: Full text of the 35 U.S.C. § 112 rejection(s). + summary_text: Summary text of the office action. + section_101_rejection_form_paragraph_text: Form paragraph text for § 101 rejection. + section_102_rejection_form_paragraph_text: Form paragraph text for § 102 rejection. + section_103_rejection_form_paragraph_text: Form paragraph text for § 103 rejection. + section_112_rejection_form_paragraph_text: Form paragraph text for § 112 rejection. + """ + + section_101_rejection_text: str | None = None + grant_date: datetime | None = None + filing_date: datetime | None = None + submission_date: datetime | None = None + examiner_employee_number: list[str] = field(default_factory=list) + section_103_rejection_text: list[str] = field(default_factory=list) + specification_title_text: list[str] = field(default_factory=list) + detail_citation_text: list[str] = field(default_factory=list) + national_subclass: list[str] = field(default_factory=list) + tech_center_number: list[str] = field(default_factory=list) + patent_application_number: list[str] = field(default_factory=list) + national_class: list[str] = field(default_factory=list) + work_group_number: list[str] = field(default_factory=list) + terminal_disclaimer_status_text: list[str] = field(default_factory=list) + group_art_unit_number: list[str] = field(default_factory=list) + proceeding_appendix_text: list[str] = field(default_factory=list) + office_action_identifier: list[str] = field(default_factory=list) + withdrawal_rejection_text: list[str] = field(default_factory=list) + obsolete_document_identifier: list[str] = field(default_factory=list) + section_102_rejection_text: list[str] = field(default_factory=list) + legacy_document_code_identifier: list[str] = field(default_factory=list) + section_112_rejection_text: list[str] = field(default_factory=list) + summary_text: list[str] = field(default_factory=list) + section_101_rejection_form_paragraph_text: list[str] = field(default_factory=list) + section_102_rejection_form_paragraph_text: list[str] = field(default_factory=list) + section_103_rejection_form_paragraph_text: list[str] = field(default_factory=list) + section_112_rejection_form_paragraph_text: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OAActionsSection": + """Create an OAActionsSection from a flat record dict containing sections.* keys. + + Args: + data: The full flat record dictionary. Keys beginning with ``sections.`` + are read as section fields. + + Returns: + OAActionsSection: An instance of OAActionsSection. + """ + + def _get_list(key: str) -> list[str]: + val = data.get(key, []) + return val if isinstance(val, list) else [] + + def _get_str(key: str) -> str | None: + val = data.get(key) + if isinstance(val, list): + return val[0] if val else None + return val if isinstance(val, str) else None + + def _get_dt(key: str) -> datetime | None: + val = data.get(key) + if isinstance(val, list): + val = val[0] if val else None + return parse_to_datetime_utc(val) + + return cls( + section_101_rejection_text=_get_str("sections.section101RejectionText"), + grant_date=_get_dt("sections.grantDate"), + filing_date=_get_dt("sections.filingDate"), + submission_date=_get_dt("sections.submissionDate"), + examiner_employee_number=_get_list("sections.examinerEmployeeNumber"), + section_103_rejection_text=_get_list("sections.section103RejectionText"), + specification_title_text=_get_list("sections.specificationTitleText"), + detail_citation_text=_get_list("sections.detailCitationText"), + national_subclass=_get_list("sections.nationalSubclass"), + tech_center_number=_get_list("sections.techCenterNumber"), + patent_application_number=_get_list("sections.patentApplicationNumber"), + national_class=_get_list("sections.nationalClass"), + work_group_number=_get_list("sections.workGroupNumber"), + terminal_disclaimer_status_text=_get_list( + "sections.terminalDisclaimerStatusText" + ), + group_art_unit_number=_get_list("sections.groupArtUnitNumber"), + proceeding_appendix_text=_get_list("sections.proceedingAppendixText"), + office_action_identifier=_get_list("sections.officeActionIdentifier"), + withdrawal_rejection_text=_get_list("sections.withdrawalRejectionText"), + obsolete_document_identifier=_get_list( + "sections.obsoleteDocumentIdentifier" + ), + section_102_rejection_text=_get_list("sections.section102RejectionText"), + legacy_document_code_identifier=_get_list( + "sections.legacyDocumentCodeIdentifier" + ), + section_112_rejection_text=_get_list("sections.section112RejectionText"), + summary_text=_get_list("sections.summaryText"), + section_101_rejection_form_paragraph_text=_get_list( + "sections.section101RejectionFormParagraphText" + ), + section_102_rejection_form_paragraph_text=_get_list( + "sections.section102RejectionFormParagraphText" + ), + section_103_rejection_form_paragraph_text=_get_list( + "sections.section103RejectionFormParagraphText" + ), + section_112_rejection_form_paragraph_text=_get_list( + "sections.section112RejectionFormParagraphText" + ), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OAActionsSection instance to a dictionary. + + Returns: + Dict[str, Any]: Flat dictionary with ``sections.*`` keys matching the + API format. None values and empty lists are omitted. + """ + d: dict[str, Any] = { + "sections.section101RejectionText": self.section_101_rejection_text, + "sections.grantDate": ( + serialize_datetime_as_naive(self.grant_date) + if self.grant_date + else None + ), + "sections.filingDate": ( + serialize_datetime_as_naive(self.filing_date) + if self.filing_date + else None + ), + "sections.submissionDate": ( + serialize_datetime_as_naive(self.submission_date) + if self.submission_date + else None + ), + "sections.examinerEmployeeNumber": self.examiner_employee_number, + "sections.section103RejectionText": self.section_103_rejection_text, + "sections.specificationTitleText": self.specification_title_text, + "sections.detailCitationText": self.detail_citation_text, + "sections.nationalSubclass": self.national_subclass, + "sections.techCenterNumber": self.tech_center_number, + "sections.patentApplicationNumber": self.patent_application_number, + "sections.nationalClass": self.national_class, + "sections.workGroupNumber": self.work_group_number, + "sections.terminalDisclaimerStatusText": self.terminal_disclaimer_status_text, + "sections.groupArtUnitNumber": self.group_art_unit_number, + "sections.proceedingAppendixText": self.proceeding_appendix_text, + "sections.officeActionIdentifier": self.office_action_identifier, + "sections.withdrawalRejectionText": self.withdrawal_rejection_text, + "sections.obsoleteDocumentIdentifier": self.obsolete_document_identifier, + "sections.section102RejectionText": self.section_102_rejection_text, + "sections.legacyDocumentCodeIdentifier": self.legacy_document_code_identifier, + "sections.section112RejectionText": self.section_112_rejection_text, + "sections.summaryText": self.summary_text, + "sections.section101RejectionFormParagraphText": self.section_101_rejection_form_paragraph_text, + "sections.section102RejectionFormParagraphText": self.section_102_rejection_form_paragraph_text, + "sections.section103RejectionFormParagraphText": self.section_103_rejection_form_paragraph_text, + "sections.section112RejectionFormParagraphText": self.section_112_rejection_form_paragraph_text, + } + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } + + +@dataclass(frozen=True) +class OAActionsRecord: + """A single Office Action document record from the OA Actions API. + + Attributes: + id: Unique document identifier (hex hash). + application_deemed_withdrawn_date: Date the application was deemed withdrawn. + work_group: Work group code(s). + filing_date: Filing date of the application. + document_active_indicator: Whether the document is active (``"0"`` = inactive). + legacy_document_code_identifier: Document code (e.g., ``"CTNF"``, ``"NOA"``). + application_status_number: Numeric application status code. + national_class: USPC national class code(s). + effective_filing_date: Effective filing date of the application. + body_text: Full text of the office action document. + obsolete_document_identifier: Legacy IFW document identifier(s). + access_level_category: Access level (e.g., ``"PUBLIC"``). + application_type_category: Application type (e.g., ``"REGULAR"``). + patent_number: Issued patent number(s). Empty list when no patent granted. + patent_application_number: Patent application number(s). + grant_date: Date the patent was granted. + submission_date: Date the office action was submitted. + customer_number: USPTO customer number. + group_art_unit_number: Art unit number (integer). + invention_title: Title of the invention. + national_subclass: USPC national subclass code(s). + patent_application_confirmation_number: Confirmation number for the application. + last_modified_timestamp: Timestamp of the last record modification. + examiner_employee_number: Examiner employee number(s). + create_date_time: Timestamp when this record was created in the database. + tech_center: Technology center code(s). + invention_subject_matter_category: Subject matter category (e.g., ``"UTL"``). + source_system_name: Source system that produced this record. + legacy_cms_identifier: Legacy CMS identifier(s). + section: Structured section data, or ``None`` if no section fields are present. + """ + + id: str = "" + application_deemed_withdrawn_date: datetime | None = None + work_group: list[str] = field(default_factory=list) + filing_date: datetime | None = None + document_active_indicator: list[str] = field(default_factory=list) + legacy_document_code_identifier: list[str] = field(default_factory=list) + application_status_number: int | None = None + national_class: list[str] = field(default_factory=list) + effective_filing_date: datetime | None = None + body_text: list[str] = field(default_factory=list) + obsolete_document_identifier: list[str] = field(default_factory=list) + access_level_category: list[str] = field(default_factory=list) + application_type_category: list[str] = field(default_factory=list) + patent_number: list[str] = field(default_factory=list) + patent_application_number: list[str] = field(default_factory=list) + grant_date: datetime | None = None + submission_date: datetime | None = None + customer_number: int | None = None + group_art_unit_number: int | None = None + invention_title: list[str] = field(default_factory=list) + national_subclass: list[str] = field(default_factory=list) + patent_application_confirmation_number: int | None = None + last_modified_timestamp: datetime | None = None + examiner_employee_number: list[str] = field(default_factory=list) + create_date_time: datetime | None = None + tech_center: list[str] = field(default_factory=list) + invention_subject_matter_category: list[str] = field(default_factory=list) + source_system_name: list[str] = field(default_factory=list) + legacy_cms_identifier: list[str] = field(default_factory=list) + section: OAActionsSection | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OAActionsRecord": + """Create an OAActionsRecord from a dictionary. + + Args: + data: Dictionary containing office action record data from API response. + May include flat ``sections.*`` keys. + + Returns: + OAActionsRecord: An instance of OAActionsRecord. + """ + + def _get_list(key: str) -> list[str]: + val = data.get(key, []) + return val if isinstance(val, list) else [] + + # Filter out literal "null" strings from patent_number + patent_number_raw = _get_list("patentNumber") + patent_number = [pn for pn in patent_number_raw if pn != "null"] + + # Detect and parse section data from flat sections.* keys + has_sections = any(k.startswith("sections.") for k in data) + section = OAActionsSection.from_dict(data) if has_sections else None + + return cls( + id=data.get("id", ""), + application_deemed_withdrawn_date=parse_to_datetime_utc( + data.get("applicationDeemedWithdrawnDate") + ), + work_group=_get_list("workGroup"), + filing_date=parse_to_datetime_utc(data.get("filingDate")), + document_active_indicator=_get_list("documentActiveIndicator"), + legacy_document_code_identifier=_get_list("legacyDocumentCodeIdentifier"), + application_status_number=data.get("applicationStatusNumber"), + national_class=_get_list("nationalClass"), + effective_filing_date=parse_to_datetime_utc( + data.get("effectiveFilingDate") + ), + body_text=_get_list("bodyText"), + obsolete_document_identifier=_get_list("obsoleteDocumentIdentifier"), + access_level_category=_get_list("accessLevelCategory"), + application_type_category=_get_list("applicationTypeCategory"), + patent_number=patent_number, + patent_application_number=_get_list("patentApplicationNumber"), + grant_date=parse_to_datetime_utc(data.get("grantDate")), + submission_date=parse_to_datetime_utc(data.get("submissionDate")), + customer_number=data.get("customerNumber"), + group_art_unit_number=data.get("groupArtUnitNumber"), + invention_title=_get_list("inventionTitle"), + national_subclass=_get_list("nationalSubclass"), + patent_application_confirmation_number=data.get( + "patentApplicationConfirmationNumber" + ), + last_modified_timestamp=parse_to_datetime_utc( + data.get("lastModifiedTimestamp") + ), + examiner_employee_number=_get_list("examinerEmployeeNumber"), + create_date_time=parse_to_datetime_utc(data.get("createDateTime")), + tech_center=_get_list("techCenter"), + invention_subject_matter_category=_get_list( + "inventionSubjectMatterCategory" + ), + source_system_name=_get_list("sourceSystemName"), + legacy_cms_identifier=_get_list("legacyCMSIdentifier"), + section=section, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OAActionsRecord instance to a dictionary. + + Returns: + Dict[str, Any]: Flat dictionary with camelCase keys matching the API format. + ``sections.*`` keys are included when section data is present. + None values and empty lists are omitted. + """ + d: dict[str, Any] = { + "id": self.id, + "applicationDeemedWithdrawnDate": ( + serialize_datetime_as_naive(self.application_deemed_withdrawn_date) + if self.application_deemed_withdrawn_date + else None + ), + "workGroup": self.work_group, + "filingDate": ( + serialize_datetime_as_naive(self.filing_date) + if self.filing_date + else None + ), + "documentActiveIndicator": self.document_active_indicator, + "legacyDocumentCodeIdentifier": self.legacy_document_code_identifier, + "applicationStatusNumber": self.application_status_number, + "nationalClass": self.national_class, + "effectiveFilingDate": ( + serialize_datetime_as_naive(self.effective_filing_date) + if self.effective_filing_date + else None + ), + "bodyText": self.body_text, + "obsoleteDocumentIdentifier": self.obsolete_document_identifier, + "accessLevelCategory": self.access_level_category, + "applicationTypeCategory": self.application_type_category, + "patentNumber": self.patent_number, + "patentApplicationNumber": self.patent_application_number, + "grantDate": ( + serialize_datetime_as_naive(self.grant_date) + if self.grant_date + else None + ), + "submissionDate": ( + serialize_datetime_as_naive(self.submission_date) + if self.submission_date + else None + ), + "customerNumber": self.customer_number, + "groupArtUnitNumber": self.group_art_unit_number, + "inventionTitle": self.invention_title, + "nationalSubclass": self.national_subclass, + "patentApplicationConfirmationNumber": self.patent_application_confirmation_number, + "lastModifiedTimestamp": ( + serialize_datetime_as_naive(self.last_modified_timestamp) + if self.last_modified_timestamp + else None + ), + "examinerEmployeeNumber": self.examiner_employee_number, + "createDateTime": ( + serialize_datetime_as_naive(self.create_date_time) + if self.create_date_time + else None + ), + "techCenter": self.tech_center, + "inventionSubjectMatterCategory": self.invention_subject_matter_category, + "sourceSystemName": self.source_system_name, + "legacyCMSIdentifier": self.legacy_cms_identifier, + } + if self.section is not None: + d.update(self.section.to_dict()) + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } + + +@dataclass(frozen=True) +class OAActionsResponse: + """Response from the OA Actions API search endpoint. + + The API returns a Solr-style response with ``start``, ``numFound``, and ``docs``. + The outer envelope key is ``"response"``. + + Attributes: + num_found: Total number of matching records. + start: The start index of the first result in this page. + docs: List of office action records in this page. + raw_data: Optional raw JSON data from the API response (for debugging). + """ + + num_found: int = 0 + start: int = 0 + docs: list[OAActionsRecord] = field(default_factory=list) + raw_data: str | None = field(default=None, compare=False, repr=False) + + @property + def count(self) -> int: + """Return total result count for pagination compatibility.""" + return self.num_found + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OAActionsResponse": + """Create an OAActionsResponse from a dictionary. + + Handles both the raw API envelope (``{"response": {...}}``) and + a pre-unwrapped dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: If True, store the raw JSON for debugging. + + Returns: + OAActionsResponse: An instance of OAActionsResponse. + """ + inner = data.get("response", data) + + docs_data = inner.get("docs", []) + docs = ( + [ + OAActionsRecord.from_dict(doc) + for doc in docs_data + if isinstance(doc, dict) + ] + if isinstance(docs_data, list) + else [] + ) + + return cls( + num_found=inner.get("numFound", 0), + start=inner.get("start", 0), + docs=docs, + raw_data=json.dumps(data) if include_raw_data else None, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OAActionsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary wrapped in the ``"response"`` envelope + matching the API format. + """ + return { + "response": { + "numFound": self.num_found, + "start": self.start, + "docs": [doc.to_dict() for doc in self.docs], + } + } + + +@dataclass(frozen=True) +class OAActionsFieldsResponse: + """Response from the OA Actions API fields endpoint. + + Contains metadata about the API including available field names + and the last data update timestamp. + + Attributes: + api_key: The dataset key (e.g., ``"oa_actions"``). + api_version_number: API version (e.g., ``"v1"``). + api_url: The URL of this fields endpoint. + api_documentation_url: URL to the Swagger documentation. + api_status: Publication status (e.g., ``"PUBLISHED"``). + field_count: Number of available fields. + fields: List of available field names. + last_data_updated_date: Timestamp of the last data update (non-standard format). + """ + + api_key: str | None = None + api_version_number: str | None = None + api_url: str | None = None + api_documentation_url: str | None = None + api_status: str | None = None + field_count: int = 0 + fields: list[str] = field(default_factory=list) + last_data_updated_date: str | None = None + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OAActionsFieldsResponse": + """Create an OAActionsFieldsResponse from a dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: Unused. Present for FromDictProtocol conformance. + + Returns: + OAActionsFieldsResponse: An instance of OAActionsFieldsResponse. + """ + fields_data = data.get("fields", []) + if not isinstance(fields_data, list): + fields_data = [] + + return cls( + api_key=data.get("apiKey"), + api_version_number=data.get("apiVersionNumber"), + api_url=data.get("apiUrl"), + api_documentation_url=data.get("apiDocumentationUrl"), + api_status=data.get("apiStatus"), + field_count=data.get("fieldCount", 0), + fields=fields_data, + last_data_updated_date=data.get("lastDataUpdatedDate"), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OAActionsFieldsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary representation with camelCase keys. + """ + d: dict[str, Any] = { + "apiKey": self.api_key, + "apiVersionNumber": self.api_version_number, + "apiUrl": self.api_url, + "apiDocumentationUrl": self.api_documentation_url, + "apiStatus": self.api_status, + "fieldCount": self.field_count, + "fields": self.fields, + "lastDataUpdatedDate": self.last_data_updated_date, + } + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } diff --git a/src/pyUSPTO/models/oa_citations.py b/src/pyUSPTO/models/oa_citations.py new file mode 100644 index 0000000..c829cff --- /dev/null +++ b/src/pyUSPTO/models/oa_citations.py @@ -0,0 +1,277 @@ +"""models.oa_citations - Data models for USPTO Office Action Citations API. + +This module provides data models for representing responses from the USPTO +Office Action Citations API (v2). These models cover citation data from +Office Actions mailed from October 1, 2017 to 30 days prior to the current +date, derived from Form PTO-892, Form PTO-1449, and Office Action text. +""" + +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from pyUSPTO.models.utils import parse_to_datetime_utc, serialize_datetime_as_naive + + +@dataclass(frozen=True) +class OACitationRecord: + """A single citation record from the OA Citations API. + + Attributes: + id: Unique record identifier (hex hash). + patent_application_number: Patent application number. + action_type_category: Type of action (e.g., ``"rejected"``). + legal_section_code: Legal section code (e.g., ``"101"``, ``"103"``). + reference_identifier: Free-text citation reference string. + parsed_reference_identifier: Extracted publication number from the reference. + group_art_unit_number: Group art unit number. + work_group: Work group code. + tech_center: Technology center code. + paragraph_number: Paragraph number within the Office Action. + applicant_cited_examiner_reference_indicator: Whether the applicant cited + this as an examiner reference. + examiner_cited_reference_indicator: Whether the examiner cited this reference. + office_action_citation_reference_indicator: Whether this is an Office Action + citation reference. + create_user_identifier: User who created the record (e.g., ``"ETL_SYS"``). + create_date_time: Timestamp when this record was created. + obsolete_document_identifier: Legacy IFW document identifier. + """ + + id: str = "" + patent_application_number: str = "" + action_type_category: str = "" + legal_section_code: str = "" + reference_identifier: str = "" + parsed_reference_identifier: str = "" + group_art_unit_number: str = "" + work_group: str = "" + tech_center: str = "" + paragraph_number: str = "" + applicant_cited_examiner_reference_indicator: bool | None = None + examiner_cited_reference_indicator: bool | None = None + office_action_citation_reference_indicator: bool | None = None + create_user_identifier: str = "" + create_date_time: datetime | None = None + obsolete_document_identifier: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OACitationRecord": + """Create an OACitationRecord from a dictionary. + + Args: + data: Dictionary containing citation record data from API response. + + Returns: + OACitationRecord: An instance of OACitationRecord. + """ + return cls( + id=data.get("id", ""), + patent_application_number=data.get("patentApplicationNumber", ""), + action_type_category=data.get("actionTypeCategory", ""), + legal_section_code=data.get("legalSectionCode", ""), + reference_identifier=data.get("referenceIdentifier", ""), + parsed_reference_identifier=data.get("parsedReferenceIdentifier", ""), + group_art_unit_number=data.get("groupArtUnitNumber", ""), + work_group=data.get("workGroup", ""), + tech_center=data.get("techCenter", ""), + paragraph_number=data.get("paragraphNumber", ""), + applicant_cited_examiner_reference_indicator=data.get( + "applicantCitedExaminerReferenceIndicator" + ), + examiner_cited_reference_indicator=data.get( + "examinerCitedReferenceIndicator" + ), + office_action_citation_reference_indicator=data.get( + "officeActionCitationReferenceIndicator" + ), + create_user_identifier=data.get("createUserIdentifier", ""), + create_date_time=parse_to_datetime_utc(data.get("createDateTime")), + obsolete_document_identifier=data.get("obsoleteDocumentIdentifier", ""), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OACitationRecord instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary with camelCase keys matching the API format. + None values are omitted. + """ + d: dict[str, Any] = { + "id": self.id, + "patentApplicationNumber": self.patent_application_number, + "actionTypeCategory": self.action_type_category, + "legalSectionCode": self.legal_section_code, + "referenceIdentifier": self.reference_identifier, + "parsedReferenceIdentifier": self.parsed_reference_identifier, + "groupArtUnitNumber": self.group_art_unit_number, + "workGroup": self.work_group, + "techCenter": self.tech_center, + "paragraphNumber": self.paragraph_number, + "applicantCitedExaminerReferenceIndicator": self.applicant_cited_examiner_reference_indicator, + "examinerCitedReferenceIndicator": self.examiner_cited_reference_indicator, + "officeActionCitationReferenceIndicator": self.office_action_citation_reference_indicator, + "createUserIdentifier": self.create_user_identifier, + "createDateTime": ( + serialize_datetime_as_naive(self.create_date_time) + if self.create_date_time + else None + ), + "obsoleteDocumentIdentifier": self.obsolete_document_identifier, + } + return {k: v for k, v in d.items() if v is not None} + + +@dataclass(frozen=True) +class OACitationsResponse: + """Response from the OA Citations API search endpoint. + + The API returns a Solr-style response with ``start``, ``numFound``, and ``docs``. + The outer envelope key is ``"response"``. + + Attributes: + num_found: Total number of matching records. + start: The start index of the first result in this page. + docs: List of citation records in this page. + raw_data: Optional raw JSON data from the API response (for debugging). + """ + + num_found: int = 0 + start: int = 0 + docs: list[OACitationRecord] = field(default_factory=list) + raw_data: str | None = field(default=None, compare=False, repr=False) + + @property + def count(self) -> int: + """Return total result count for pagination compatibility.""" + return self.num_found + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OACitationsResponse": + """Create an OACitationsResponse from a dictionary. + + Handles both the raw API envelope (``{"response": {...}}``) and + a pre-unwrapped dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: If True, store the raw JSON for debugging. + + Returns: + OACitationsResponse: An instance of OACitationsResponse. + """ + inner = data.get("response", data) + + docs_data = inner.get("docs", []) + docs = ( + [ + OACitationRecord.from_dict(doc) + for doc in docs_data + if isinstance(doc, dict) + ] + if isinstance(docs_data, list) + else [] + ) + + return cls( + num_found=inner.get("numFound", 0), + start=inner.get("start", 0), + docs=docs, + raw_data=json.dumps(data) if include_raw_data else None, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OACitationsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary wrapped in the ``"response"`` envelope + matching the API format. + """ + return { + "response": { + "numFound": self.num_found, + "start": self.start, + "docs": [doc.to_dict() for doc in self.docs], + } + } + + +@dataclass(frozen=True) +class OACitationsFieldsResponse: + """Response from the OA Citations API fields endpoint. + + Contains metadata about the API including available field names + and the last data update timestamp. + + Attributes: + api_key: The dataset key (e.g., ``"oa_citations"``). + api_version_number: API version (e.g., ``"v2"``). + api_url: The URL of this fields endpoint. + api_documentation_url: URL to the API documentation. + api_status: Publication status (e.g., ``"PUBLISHED"``). + field_count: Number of available fields. + fields: List of available field names. + last_data_updated_date: Timestamp of the last data update (non-standard format). + """ + + api_key: str | None = None + api_version_number: str | None = None + api_url: str | None = None + api_documentation_url: str | None = None + api_status: str | None = None + field_count: int = 0 + fields: list[str] = field(default_factory=list) + last_data_updated_date: str | None = None + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OACitationsFieldsResponse": + """Create an OACitationsFieldsResponse from a dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: Unused. Present for FromDictProtocol conformance. + + Returns: + OACitationsFieldsResponse: An instance of OACitationsFieldsResponse. + """ + fields_data = data.get("fields", []) + if not isinstance(fields_data, list): + fields_data = [] + + return cls( + api_key=data.get("apiKey"), + api_version_number=data.get("apiVersionNumber"), + api_url=data.get("apiUrl"), + api_documentation_url=data.get("apiDocumentationUrl"), + api_status=data.get("apiStatus"), + field_count=data.get("fieldCount", 0), + fields=fields_data, + last_data_updated_date=data.get("lastDataUpdatedDate"), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OACitationsFieldsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary representation with camelCase keys. + """ + d: dict[str, Any] = { + "apiKey": self.api_key, + "apiVersionNumber": self.api_version_number, + "apiUrl": self.api_url, + "apiDocumentationUrl": self.api_documentation_url, + "apiStatus": self.api_status, + "fieldCount": self.field_count, + "fields": self.fields, + "lastDataUpdatedDate": self.last_data_updated_date, + } + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } diff --git a/src/pyUSPTO/models/oa_rejections.py b/src/pyUSPTO/models/oa_rejections.py new file mode 100644 index 0000000..9149c51 --- /dev/null +++ b/src/pyUSPTO/models/oa_rejections.py @@ -0,0 +1,369 @@ +"""models.oa_rejections - Data models for USPTO Office Action Rejections API. + +This module provides data models for representing responses from the USPTO +Office Action Rejections API (v2). These models cover rejection-level data +from Office Actions including rejection type indicators, claim arrays, and +examiner classification metadata. +""" + +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from pyUSPTO.models.utils import parse_to_datetime_utc, serialize_datetime_as_naive + + +@dataclass(frozen=True) +class OARejectionsRecord: + """A single rejection record from the OA Rejections API. + + Attributes: + id: Unique record identifier (hex hash). + patent_application_number: USPTO patent application number. + legacy_document_code_identifier: Document code (e.g., ``"CTNF"``, ``"NOA"``). + action_type_category: Type of office action (e.g., ``"rejected"``). + legal_section_code: Legal provision under which the action was taken. + group_art_unit_number: Examiner group art unit (e.g., ``"1713"``). + national_class: USPC national class code. + national_subclass: USPC national subclass code. + paragraph_number: Paragraph number referenced in the action. + obsolete_document_identifier: Legacy IFW document identifier. + create_user_identifier: Job identifier that inserted this record. + claim_number_array_document: Claim numbers referenced in this record, + split from the API's comma-separated string format. + submission_date: Date the office action was submitted. + create_date_time: Timestamp when this record was inserted into the database. + has_rej_101: Whether a 35 U.S.C. § 101 rejection was raised. + has_rej_102: Whether a 35 U.S.C. § 102 rejection was raised. + has_rej_103: Whether a 35 U.S.C. § 103 rejection was raised. + has_rej_112: Whether a 35 U.S.C. § 112 rejection was raised. + has_rej_dp: Whether a non-statutory double patenting rejection was raised. + cite_103_max: Largest number of references in any single § 103 rejection. + cite_103_eq1: Whether exactly one reference was cited in a § 103 rejection. + cite_103_gt3: Whether more than three references were cited in a § 103 rejection. + closing_missing: Whether the closing paragraph is missing from the action. + reject_form_missmatch: Whether the form content doesn't match the document code. + Note: field name preserves the API's original spelling. + form_paragraph_missing: Whether a required form paragraph is missing. + header_missing: Whether the standard metadata header is missing. + bilski_indicator: Whether the Bilski v. Kappos decision is referenced. + mayo_indicator: Whether the Mayo v. Prometheus decision is referenced. + alice_indicator: Whether the Alice/Mayo framework is applied for § 101 review. + myriad_indicator: Whether the Myriad Genetics decision is applied. + allowed_claim_indicator: Whether the application contains allowed claims. + """ + + id: str = "" + patent_application_number: str | None = None + legacy_document_code_identifier: str | None = None + action_type_category: str | None = None + legal_section_code: str | None = None + group_art_unit_number: str | None = None + national_class: str | None = None + national_subclass: str | None = None + paragraph_number: str | None = None + obsolete_document_identifier: str | None = None + create_user_identifier: str | None = None + claim_number_array_document: list[str] = field(default_factory=list) + submission_date: datetime | None = None + create_date_time: datetime | None = None + has_rej_101: bool | None = None + has_rej_102: bool | None = None + has_rej_103: bool | None = None + has_rej_112: bool | None = None + has_rej_dp: bool | None = None + cite_103_max: int | None = None + cite_103_eq1: int | None = None + cite_103_gt3: int | None = None + closing_missing: int | None = None + reject_form_missmatch: int | None = None + form_paragraph_missing: int | None = None + header_missing: int | None = None + bilski_indicator: bool | None = None + mayo_indicator: bool | None = None + alice_indicator: bool | None = None + myriad_indicator: bool | None = None + allowed_claim_indicator: bool | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "OARejectionsRecord": + """Create an OARejectionsRecord from a dictionary. + + Args: + data: Dictionary containing rejection record data from API response. + + Returns: + OARejectionsRecord: An instance of OARejectionsRecord. + """ + + def _get_bool(key: str) -> bool | None: + val = data.get(key) + if val is None: + return None + return bool(val) + + def _get_int(key: str) -> int | None: + val = data.get(key) + if val is None: + return None + return int(val) + + # Split comma-separated claim numbers from the list-of-strings API format + raw_claims = data.get("claimNumberArrayDocument", []) + if not isinstance(raw_claims, list): + raw_claims = [] + claim_number_array_document: list[str] = [] + for item in raw_claims: + if isinstance(item, str): + claim_number_array_document.extend( + s.strip() for s in item.split(",") if s.strip() + ) + + return cls( + id=data.get("id", ""), + patent_application_number=data.get("patentApplicationNumber"), + legacy_document_code_identifier=data.get("legacyDocumentCodeIdentifier"), + action_type_category=data.get("actionTypeCategory"), + legal_section_code=data.get("legalSectionCode"), + group_art_unit_number=data.get("groupArtUnitNumber"), + national_class=data.get("nationalClass"), + national_subclass=data.get("nationalSubclass"), + paragraph_number=data.get("paragraphNumber"), + obsolete_document_identifier=data.get("obsoleteDocumentIdentifier"), + create_user_identifier=data.get("createUserIdentifier"), + claim_number_array_document=claim_number_array_document, + submission_date=parse_to_datetime_utc(data.get("submissionDate")), + create_date_time=parse_to_datetime_utc(data.get("createDateTime")), + has_rej_101=_get_bool("hasRej101"), + has_rej_102=_get_bool("hasRej102"), + has_rej_103=_get_bool("hasRej103"), + has_rej_112=_get_bool("hasRej112"), + has_rej_dp=_get_bool("hasRejDP"), + cite_103_max=_get_int("cite103Max"), + cite_103_eq1=_get_int("cite103EQ1"), + cite_103_gt3=_get_int("cite103GT3"), + closing_missing=_get_int("closingMissing"), + reject_form_missmatch=_get_int("rejectFormMissmatch"), + form_paragraph_missing=_get_int("formParagraphMissing"), + header_missing=_get_int("headerMissing"), + bilski_indicator=_get_bool("bilskiIndicator"), + mayo_indicator=_get_bool("mayoIndicator"), + alice_indicator=_get_bool("aliceIndicator"), + myriad_indicator=_get_bool("myriadIndicator"), + allowed_claim_indicator=_get_bool("allowedClaimIndicator"), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OARejectionsRecord instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary with camelCase keys matching the API format. + Claim numbers are joined back to a comma-separated string in a list. + None values and empty lists are omitted. + """ + claims_serialized = ( + [",".join(self.claim_number_array_document)] + if self.claim_number_array_document + else [] + ) + d: dict[str, Any] = { + "id": self.id, + "patentApplicationNumber": self.patent_application_number, + "legacyDocumentCodeIdentifier": self.legacy_document_code_identifier, + "actionTypeCategory": self.action_type_category, + "legalSectionCode": self.legal_section_code, + "groupArtUnitNumber": self.group_art_unit_number, + "nationalClass": self.national_class, + "nationalSubclass": self.national_subclass, + "paragraphNumber": self.paragraph_number, + "obsoleteDocumentIdentifier": self.obsolete_document_identifier, + "createUserIdentifier": self.create_user_identifier, + "claimNumberArrayDocument": claims_serialized, + "submissionDate": ( + serialize_datetime_as_naive(self.submission_date) + if self.submission_date + else None + ), + "createDateTime": ( + serialize_datetime_as_naive(self.create_date_time) + if self.create_date_time + else None + ), + "hasRej101": self.has_rej_101, + "hasRej102": self.has_rej_102, + "hasRej103": self.has_rej_103, + "hasRej112": self.has_rej_112, + "hasRejDP": self.has_rej_dp, + "cite103Max": self.cite_103_max, + "cite103EQ1": self.cite_103_eq1, + "cite103GT3": self.cite_103_gt3, + "closingMissing": self.closing_missing, + "rejectFormMissmatch": self.reject_form_missmatch, + "formParagraphMissing": self.form_paragraph_missing, + "headerMissing": self.header_missing, + "bilskiIndicator": self.bilski_indicator, + "mayoIndicator": self.mayo_indicator, + "aliceIndicator": self.alice_indicator, + "myriadIndicator": self.myriad_indicator, + "allowedClaimIndicator": self.allowed_claim_indicator, + } + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } + + +@dataclass(frozen=True) +class OARejectionsResponse: + """Response from the OA Rejections API search endpoint. + + The API returns a Solr-style response with ``start``, ``numFound``, and ``docs``. + The outer envelope key is ``"response"``. + + Attributes: + num_found: Total number of matching records. + start: The start index of the first result in this page. + docs: List of rejection records in this page. + raw_data: Optional raw JSON data from the API response (for debugging). + """ + + num_found: int = 0 + start: int = 0 + docs: list[OARejectionsRecord] = field(default_factory=list) + raw_data: str | None = field(default=None, compare=False, repr=False) + + @property + def count(self) -> int: + """Return total result count for pagination compatibility.""" + return self.num_found + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OARejectionsResponse": + """Create an OARejectionsResponse from a dictionary. + + Handles both the raw API envelope (``{"response": {...}}``) and + a pre-unwrapped dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: If True, store the raw JSON for debugging. + + Returns: + OARejectionsResponse: An instance of OARejectionsResponse. + """ + inner = data.get("response", data) + + docs_data = inner.get("docs", []) + docs = ( + [ + OARejectionsRecord.from_dict(doc) + for doc in docs_data + if isinstance(doc, dict) + ] + if isinstance(docs_data, list) + else [] + ) + + return cls( + num_found=inner.get("numFound", 0), + start=inner.get("start", 0), + docs=docs, + raw_data=json.dumps(data) if include_raw_data else None, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OARejectionsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary wrapped in the ``"response"`` envelope + matching the API format. + """ + return { + "response": { + "numFound": self.num_found, + "start": self.start, + "docs": [doc.to_dict() for doc in self.docs], + } + } + + +@dataclass(frozen=True) +class OARejectionsFieldsResponse: + """Response from the OA Rejections API fields endpoint. + + Contains metadata about the API including available field names + and the last data update timestamp. + + Attributes: + api_key: The dataset key (e.g., ``"oa_rejections"``). + api_version_number: API version (e.g., ``"v2"``). + api_url: The URL of this fields endpoint. + api_documentation_url: URL to the Swagger documentation. + api_status: Publication status (e.g., ``"PUBLISHED"``). + field_count: Number of available fields. + fields: List of available field names. + last_data_updated_date: Timestamp of the last data update (non-standard format). + """ + + api_key: str | None = None + api_version_number: str | None = None + api_url: str | None = None + api_documentation_url: str | None = None + api_status: str | None = None + field_count: int = 0 + fields: list[str] = field(default_factory=list) + last_data_updated_date: str | None = None + + @classmethod + def from_dict( + cls, data: dict[str, Any], include_raw_data: bool = False + ) -> "OARejectionsFieldsResponse": + """Create an OARejectionsFieldsResponse from a dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: Unused. Present for FromDictProtocol conformance. + + Returns: + OARejectionsFieldsResponse: An instance of OARejectionsFieldsResponse. + """ + fields_data = data.get("fields", []) + if not isinstance(fields_data, list): + fields_data = [] + + return cls( + api_key=data.get("apiKey"), + api_version_number=data.get("apiVersionNumber"), + api_url=data.get("apiUrl"), + api_documentation_url=data.get("apiDocumentationUrl"), + api_status=data.get("apiStatus"), + field_count=data.get("fieldCount", 0), + fields=fields_data, + last_data_updated_date=data.get("lastDataUpdatedDate"), + ) + + def to_dict(self) -> dict[str, Any]: + """Convert the OARejectionsFieldsResponse instance to a dictionary. + + Returns: + Dict[str, Any]: Dictionary representation with camelCase keys. + """ + d: dict[str, Any] = { + "apiKey": self.api_key, + "apiVersionNumber": self.api_version_number, + "apiUrl": self.api_url, + "apiDocumentationUrl": self.api_documentation_url, + "apiStatus": self.api_status, + "fieldCount": self.field_count, + "fields": self.fields, + "lastDataUpdatedDate": self.last_data_updated_date, + } + return { + k: v + for k, v in d.items() + if v is not None and (not isinstance(v, list) or v) + } diff --git a/tests/clients/test_oa_actions_clients.py b/tests/clients/test_oa_actions_clients.py new file mode 100644 index 0000000..e408d53 --- /dev/null +++ b/tests/clients/test_oa_actions_clients.py @@ -0,0 +1,448 @@ +"""Tests for the pyUSPTO.clients.oa_actions.OAActionsClient. + +This module contains comprehensive tests for initialization, search functionality, +field retrieval, and pagination. +""" + +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from pyUSPTO.clients.oa_actions import OAActionsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, +) + +# --- Fixtures --- + + +@pytest.fixture +def api_key_fixture() -> str: + return "test_key" + + +@pytest.fixture +def uspto_config(api_key_fixture: str) -> USPTOConfig: + return USPTOConfig(api_key=api_key_fixture) + + +@pytest.fixture +def oa_actions_client(uspto_config: USPTOConfig) -> OAActionsClient: + return OAActionsClient(config=uspto_config) + + +@pytest.fixture +def mock_record() -> OAActionsRecord: + return OAActionsRecord( + id="813869284108aad9fc4821419bb120d78f2a1e69db5a33d77e16f396", + patent_application_number=["14485382"], + legacy_document_code_identifier=["NOA"], + tech_center=["1700"], + group_art_unit_number=1712, + ) + + +@pytest.fixture +def mock_response_with_data(mock_record: OAActionsRecord) -> OAActionsResponse: + return OAActionsResponse(num_found=1, start=0, docs=[mock_record]) + + +@pytest.fixture +def mock_response_empty() -> OAActionsResponse: + return OAActionsResponse(num_found=0, start=0, docs=[]) + + +@pytest.fixture +def client_with_mocked_request( + oa_actions_client: OAActionsClient, +) -> Iterator[tuple[OAActionsClient, MagicMock]]: + with patch.object(oa_actions_client, "_get_model", autospec=True) as mock_get_model: + yield oa_actions_client, mock_get_model + + +# --- TestInit --- + + +class TestOAActionsClientInit: + def test_default_base_url(self, uspto_config: USPTOConfig) -> None: + client = OAActionsClient(config=uspto_config) + assert client.base_url == "https://api.uspto.gov" + + def test_custom_base_url(self, uspto_config: USPTOConfig) -> None: + client = OAActionsClient( + config=uspto_config, base_url="https://custom.example.com" + ) + assert client.base_url == "https://custom.example.com" + + def test_config_base_url(self) -> None: + config = USPTOConfig( + api_key="test", + oa_actions_base_url="https://config.example.com", + ) + client = OAActionsClient(config=config) + assert client.base_url == "https://config.example.com" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("USPTO_API_KEY", "env_key") + client = OAActionsClient() + assert client.base_url == "https://api.uspto.gov" + + def test_custom_url_overrides_config(self) -> None: + config = USPTOConfig( + api_key="test", + oa_actions_base_url="https://config.example.com", + ) + client = OAActionsClient(config=config, base_url="https://override.example.com") + assert client.base_url == "https://override.example.com" + + +# --- TestSearch --- + + +class TestOAActionsClientSearch: + def test_post_body_passthrough( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + post_body = {"criteria": "techCenter:1700", "rows": 50} + result = client.search(post_body=post_body) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_actions/v1/records", + response_class=OAActionsResponse, + json_data=post_body, + params=None, + ) + assert result is mock_response_with_data + + def test_post_body_with_additional_params( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + extra = {"fl": "id,patentApplicationNumber"} + client.search(post_body={"criteria": "*:*"}, additional_query_params=extra) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_actions/v1/records", + response_class=OAActionsResponse, + json_data={"criteria": "*:*"}, + params=extra, + ) + + def test_direct_criteria( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(criteria="patentApplicationNumber:14485382") + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_actions/v1/records", + response_class=OAActionsResponse, + json_data={ + "criteria": "patentApplicationNumber:14485382", + "start": 0, + "rows": 25, + }, + ) + + def test_patent_application_number_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="14485382") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "patentApplicationNumber:14485382" + ) + + def test_legacy_document_code_identifier_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(legacy_document_code_identifier_q="CTNF") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "legacyDocumentCodeIdentifier:CTNF" + ) + + def test_group_art_unit_number_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(group_art_unit_number_q=2889) + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "groupArtUnitNumber:2889" + + def test_tech_center_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(tech_center_q="2800") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "techCenter:2800" + + def test_access_level_category_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(access_level_category_q="PUBLIC") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "accessLevelCategory:PUBLIC" + + def test_application_type_category_q( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(application_type_category_q="REGULAR") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "applicationTypeCategory:REGULAR" + + def test_submission_date_range( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + submission_date_from_q="2019-01-01", + submission_date_to_q="2019-12-31", + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "submissionDate:[2019-01-01 TO 2019-12-31]" + ) + + def test_submission_date_from_only( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(submission_date_from_q="2019-01-01") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "submissionDate:>=2019-01-01" + + def test_submission_date_to_only( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(submission_date_to_q="2019-12-31") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "submissionDate:<=2019-12-31" + + def test_combined_convenience_params( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + tech_center_q="2800", + legacy_document_code_identifier_q="CTNF", + ) + + call_kwargs = mock_get_model.call_args.kwargs + criteria = call_kwargs["json_data"]["criteria"] + assert "techCenter:2800" in criteria + assert "legacyDocumentCodeIdentifier:CTNF" in criteria + assert " AND " in criteria + + def test_defaults_injected( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="12345678") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["start"] == 0 + assert call_kwargs["json_data"]["rows"] == 25 + + def test_sort_included( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(tech_center_q="1700", sort="submissionDate desc") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["sort"] == "submissionDate desc" + + def test_no_criteria_no_body_key( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search() + + call_kwargs = mock_get_model.call_args.kwargs + assert "criteria" not in call_kwargs["json_data"] + + def test_additional_query_params_merged_into_body( + self, + client_with_mocked_request: tuple[OAActionsClient, MagicMock], + mock_response_with_data: OAActionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + tech_center_q="1700", + additional_query_params={"fl": "id,patentApplicationNumber"}, + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["fl"] == "id,patentApplicationNumber" + assert "techCenter:1700" in call_kwargs["json_data"]["criteria"] + + +# --- TestGetFields --- + + +class TestOAActionsClientGetFields: + def test_get_fields_calls_correct_endpoint( + self, oa_actions_client: OAActionsClient + ) -> None: + mock_fields = OAActionsFieldsResponse( + api_key="oa_actions", + api_status="PUBLISHED", + field_count=56, + fields=["patentApplicationNumber", "bodyText"], + ) + with patch.object( + oa_actions_client, "_get_model", autospec=True + ) as mock_get_model: + mock_get_model.return_value = mock_fields + result = oa_actions_client.get_fields() + + mock_get_model.assert_called_once_with( + method="GET", + endpoint="api/v1/patent/oa/oa_actions/v1/fields", + response_class=OAActionsFieldsResponse, + ) + assert result is mock_fields + + +# --- TestPaginate --- + + +class TestOAActionsClientPaginate: + def test_delegates_to_paginate_solr_results( + self, oa_actions_client: OAActionsClient + ) -> None: + with patch.object( + oa_actions_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + oa_actions_client.paginate(tech_center_q="2800", rows=10) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=None, + tech_center_q="2800", + rows=10, + ) + + def test_passes_post_body(self, oa_actions_client: OAActionsClient) -> None: + with patch.object( + oa_actions_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + post_body = {"criteria": "techCenter:2800", "rows": 50} + oa_actions_client.paginate(post_body=post_body) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=post_body, + ) + + def test_yields_records( + self, + oa_actions_client: OAActionsClient, + mock_record: OAActionsRecord, + mock_response_with_data: OAActionsResponse, + ) -> None: + with patch.object(oa_actions_client, "search", autospec=True) as mock_search: + # Two pages: first has data, second is empty to stop pagination + mock_search.side_effect = [ + mock_response_with_data, + OAActionsResponse(num_found=0, start=0, docs=[]), + ] + results = list(oa_actions_client.paginate(tech_center_q="1700")) + + assert len(results) == 1 + assert results[0] is mock_record diff --git a/tests/clients/test_oa_citations_clients.py b/tests/clients/test_oa_citations_clients.py new file mode 100644 index 0000000..15ed80c --- /dev/null +++ b/tests/clients/test_oa_citations_clients.py @@ -0,0 +1,493 @@ +"""Tests for the pyUSPTO.clients.oa_citations.OACitationsClient. + +This module contains comprehensive tests for initialization, search functionality, +field retrieval, and pagination. +""" + +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from pyUSPTO.clients.oa_citations import OACitationsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) + +# --- Fixtures --- + + +@pytest.fixture +def api_key_fixture() -> str: + return "test_key" + + +@pytest.fixture +def uspto_config(api_key_fixture: str) -> USPTOConfig: + return USPTOConfig(api_key=api_key_fixture) + + +@pytest.fixture +def oa_citations_client(uspto_config: USPTOConfig) -> OACitationsClient: + return OACitationsClient(config=uspto_config) + + +@pytest.fixture +def mock_record() -> OACitationRecord: + return OACitationRecord( + id="90d4b51ab322a638b1327494a7129975", + patent_application_number="17519936", + action_type_category="rejected", + legal_section_code="103", + tech_center="2800", + group_art_unit_number="2858", + examiner_cited_reference_indicator=True, + ) + + +@pytest.fixture +def mock_response_with_data(mock_record: OACitationRecord) -> OACitationsResponse: + return OACitationsResponse(num_found=1, start=0, docs=[mock_record]) + + +@pytest.fixture +def mock_response_empty() -> OACitationsResponse: + return OACitationsResponse(num_found=0, start=0, docs=[]) + + +@pytest.fixture +def client_with_mocked_request( + oa_citations_client: OACitationsClient, +) -> Iterator[tuple[OACitationsClient, MagicMock]]: + with patch.object( + oa_citations_client, "_get_model", autospec=True + ) as mock_get_model: + yield oa_citations_client, mock_get_model + + +# --- TestInit --- + + +class TestOACitationsClientInit: + def test_default_base_url(self, uspto_config: USPTOConfig) -> None: + client = OACitationsClient(config=uspto_config) + assert client.base_url == "https://api.uspto.gov" + + def test_custom_base_url(self, uspto_config: USPTOConfig) -> None: + client = OACitationsClient( + config=uspto_config, base_url="https://custom.example.com" + ) + assert client.base_url == "https://custom.example.com" + + def test_config_base_url(self) -> None: + config = USPTOConfig( + api_key="test", + oa_citations_base_url="https://config.example.com", + ) + client = OACitationsClient(config=config) + assert client.base_url == "https://config.example.com" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("USPTO_API_KEY", "env_key") + client = OACitationsClient() + assert client.base_url == "https://api.uspto.gov" + + def test_custom_url_overrides_config(self) -> None: + config = USPTOConfig( + api_key="test", + oa_citations_base_url="https://config.example.com", + ) + client = OACitationsClient( + config=config, base_url="https://override.example.com" + ) + assert client.base_url == "https://override.example.com" + + +# --- TestSearch --- + + +class TestOACitationsClientSearch: + def test_post_body_passthrough( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + post_body = {"criteria": "techCenter:2800", "rows": 50} + result = client.search(post_body=post_body) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_citations/v2/records", + response_class=OACitationsResponse, + json_data=post_body, + params=None, + ) + assert result is mock_response_with_data + + def test_post_body_with_additional_params( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + extra = {"fl": "id,patentApplicationNumber"} + client.search(post_body={"criteria": "*:*"}, additional_query_params=extra) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_citations/v2/records", + response_class=OACitationsResponse, + json_data={"criteria": "*:*"}, + params=extra, + ) + + def test_direct_criteria( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(criteria="patentApplicationNumber:17519936") + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_citations/v2/records", + response_class=OACitationsResponse, + json_data={ + "criteria": "patentApplicationNumber:17519936", + "start": 0, + "rows": 25, + }, + ) + + def test_patent_application_number_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="17519936") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "patentApplicationNumber:17519936" + ) + + def test_legal_section_code_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(legal_section_code_q="103") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "legalSectionCode:*103*" + + def test_action_type_category_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(action_type_category_q="rejected") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "actionTypeCategory:rejected" + + def test_tech_center_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(tech_center_q="2800") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "techCenter:2800" + + def test_work_group_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(work_group_q="2850") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "workGroup:2850" + + def test_group_art_unit_number_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(group_art_unit_number_q="2858") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "groupArtUnitNumber:2858" + + def test_examiner_cited_reference_indicator_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(examiner_cited_reference_indicator_q=True) + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "examinerCitedReferenceIndicator:true" + ) + + def test_applicant_cited_examiner_reference_indicator_q( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(applicant_cited_examiner_reference_indicator_q=False) + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "applicantCitedExaminerReferenceIndicator:false" + ) + + def test_create_date_time_range( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + create_date_time_from_q="2025-01-01", + create_date_time_to_q="2025-06-30", + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "createDateTime:[2025-01-01 TO 2025-06-30]" + ) + + def test_create_date_time_from_only( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(create_date_time_from_q="2025-01-01") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "createDateTime:>=2025-01-01" + ) + + def test_create_date_time_to_only( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(create_date_time_to_q="2025-06-30") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "createDateTime:<=2025-06-30" + ) + + def test_combined_convenience_params( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + tech_center_q="2800", + legal_section_code_q="103", + ) + + call_kwargs = mock_get_model.call_args.kwargs + criteria = call_kwargs["json_data"]["criteria"] + assert "techCenter:2800" in criteria + assert "legalSectionCode:*103*" in criteria + assert " AND " in criteria + + def test_defaults_injected( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="17519936") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["start"] == 0 + assert call_kwargs["json_data"]["rows"] == 25 + + def test_sort_included( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(tech_center_q="2800", sort="createDateTime desc") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["sort"] == "createDateTime desc" + + def test_no_criteria_no_body_key( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search() + + call_kwargs = mock_get_model.call_args.kwargs + assert "criteria" not in call_kwargs["json_data"] + + def test_additional_query_params_merged_into_body( + self, + client_with_mocked_request: tuple[OACitationsClient, MagicMock], + mock_response_with_data: OACitationsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + tech_center_q="2800", + additional_query_params={"fl": "id,patentApplicationNumber"}, + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["fl"] == "id,patentApplicationNumber" + assert "techCenter:2800" in call_kwargs["json_data"]["criteria"] + + +# --- TestGetFields --- + + +class TestOACitationsClientGetFields: + def test_get_fields_calls_correct_endpoint( + self, oa_citations_client: OACitationsClient + ) -> None: + mock_fields = OACitationsFieldsResponse( + api_key="oa_citations", + api_status="PUBLISHED", + field_count=16, + fields=["patentApplicationNumber", "legalSectionCode"], + ) + with patch.object( + oa_citations_client, "_get_model", autospec=True + ) as mock_get_model: + mock_get_model.return_value = mock_fields + result = oa_citations_client.get_fields() + + mock_get_model.assert_called_once_with( + method="GET", + endpoint="api/v1/patent/oa/oa_citations/v2/fields", + response_class=OACitationsFieldsResponse, + ) + assert result is mock_fields + + +# --- TestPaginate --- + + +class TestOACitationsClientPaginate: + def test_delegates_to_paginate_solr_results( + self, oa_citations_client: OACitationsClient + ) -> None: + with patch.object( + oa_citations_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + oa_citations_client.paginate(tech_center_q="2800", rows=10) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=None, + tech_center_q="2800", + rows=10, + ) + + def test_passes_post_body( + self, oa_citations_client: OACitationsClient + ) -> None: + with patch.object( + oa_citations_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + post_body = {"criteria": "techCenter:2800", "rows": 50} + oa_citations_client.paginate(post_body=post_body) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=post_body, + ) + + def test_yields_records( + self, + oa_citations_client: OACitationsClient, + mock_record: OACitationRecord, + mock_response_with_data: OACitationsResponse, + ) -> None: + with patch.object( + oa_citations_client, "search", autospec=True + ) as mock_search: + # Two pages: first has data, second is empty to stop pagination + mock_search.side_effect = [ + mock_response_with_data, + OACitationsResponse(num_found=0, start=0, docs=[]), + ] + results = list(oa_citations_client.paginate(tech_center_q="2800")) + + assert len(results) == 1 + assert results[0] is mock_record diff --git a/tests/clients/test_oa_rejections_clients.py b/tests/clients/test_oa_rejections_clients.py new file mode 100644 index 0000000..571faee --- /dev/null +++ b/tests/clients/test_oa_rejections_clients.py @@ -0,0 +1,444 @@ +"""Tests for the pyUSPTO.clients.oa_rejections.OARejectionsClient. + +This module contains comprehensive tests for initialization, search functionality, +field retrieval, and pagination. +""" + +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from pyUSPTO.clients.oa_rejections import OARejectionsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) + +# --- Fixtures --- + + +@pytest.fixture +def api_key_fixture() -> str: + return "test_key" + + +@pytest.fixture +def uspto_config(api_key_fixture: str) -> USPTOConfig: + return USPTOConfig(api_key=api_key_fixture) + + +@pytest.fixture +def oa_rejections_client(uspto_config: USPTOConfig) -> OARejectionsClient: + return OARejectionsClient(config=uspto_config) + + +@pytest.fixture +def mock_record() -> OARejectionsRecord: + return OARejectionsRecord( + id="14642e2cc522ac577468fb6fc026d135", + patent_application_number="12190351", + legacy_document_code_identifier="CTNF", + group_art_unit_number="1713", + has_rej_103=True, + ) + + +@pytest.fixture +def mock_response_with_data(mock_record: OARejectionsRecord) -> OARejectionsResponse: + return OARejectionsResponse(num_found=1, start=0, docs=[mock_record]) + + +@pytest.fixture +def mock_response_empty() -> OARejectionsResponse: + return OARejectionsResponse(num_found=0, start=0, docs=[]) + + +@pytest.fixture +def client_with_mocked_request( + oa_rejections_client: OARejectionsClient, +) -> Iterator[tuple[OARejectionsClient, MagicMock]]: + with patch.object( + oa_rejections_client, "_get_model", autospec=True + ) as mock_get_model: + yield oa_rejections_client, mock_get_model + + +# --- TestInit --- + + +class TestOARejectionsClientInit: + def test_default_base_url(self, uspto_config: USPTOConfig) -> None: + client = OARejectionsClient(config=uspto_config) + assert client.base_url == "https://api.uspto.gov" + + def test_custom_base_url(self, uspto_config: USPTOConfig) -> None: + client = OARejectionsClient( + config=uspto_config, base_url="https://custom.example.com" + ) + assert client.base_url == "https://custom.example.com" + + def test_config_base_url(self) -> None: + config = USPTOConfig( + api_key="test", + oa_rejections_base_url="https://config.example.com", + ) + client = OARejectionsClient(config=config) + assert client.base_url == "https://config.example.com" + + def test_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("USPTO_API_KEY", "env_key") + client = OARejectionsClient() + assert client.base_url == "https://api.uspto.gov" + + def test_custom_url_overrides_config(self) -> None: + config = USPTOConfig( + api_key="test", + oa_rejections_base_url="https://config.example.com", + ) + client = OARejectionsClient( + config=config, base_url="https://override.example.com" + ) + assert client.base_url == "https://override.example.com" + + +# --- TestSearch --- + + +class TestOARejectionsClientSearch: + def test_post_body_passthrough( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + post_body = {"criteria": "hasRej103:1", "rows": 50} + result = client.search(post_body=post_body) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_rejections/v2/records", + response_class=OARejectionsResponse, + json_data=post_body, + params=None, + ) + assert result is mock_response_with_data + + def test_post_body_with_additional_params( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + extra = {"fl": "id,patentApplicationNumber"} + client.search(post_body={"criteria": "*:*"}, additional_query_params=extra) + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_rejections/v2/records", + response_class=OARejectionsResponse, + json_data={"criteria": "*:*"}, + params=extra, + ) + + def test_direct_criteria( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(criteria="patentApplicationNumber:12190351") + + mock_get_model.assert_called_once_with( + method="POST", + endpoint="api/v1/patent/oa/oa_rejections/v2/records", + response_class=OARejectionsResponse, + json_data={ + "criteria": "patentApplicationNumber:12190351", + "start": 0, + "rows": 25, + }, + ) + + def test_patent_application_number_q( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="12190351") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "patentApplicationNumber:12190351" + ) + + def test_legacy_document_code_identifier_q( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(legacy_document_code_identifier_q="CTNF") + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] == "legacyDocumentCodeIdentifier:CTNF" + ) + + def test_group_art_unit_number_q( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(group_art_unit_number_q="1713") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "groupArtUnitNumber:1713" + + def test_legal_section_code_q( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(legal_section_code_q="112") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "legalSectionCode:112" + + def test_action_type_category_q( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(action_type_category_q="rejected") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "actionTypeCategory:rejected" + + def test_submission_date_range( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + submission_date_from_q="2019-01-01", + submission_date_to_q="2019-12-31", + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert ( + call_kwargs["json_data"]["criteria"] + == "submissionDate:[2019-01-01 TO 2019-12-31]" + ) + + def test_submission_date_from_only( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(submission_date_from_q="2019-01-01") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "submissionDate:>=2019-01-01" + + def test_submission_date_to_only( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(submission_date_to_q="2019-12-31") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["criteria"] == "submissionDate:<=2019-12-31" + + def test_combined_convenience_params( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + legacy_document_code_identifier_q="CTNF", + group_art_unit_number_q="1700", + ) + + call_kwargs = mock_get_model.call_args.kwargs + criteria = call_kwargs["json_data"]["criteria"] + assert "legacyDocumentCodeIdentifier:CTNF" in criteria + assert "groupArtUnitNumber:1700" in criteria + assert " AND " in criteria + + def test_defaults_injected( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search(patent_application_number_q="12190351") + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["start"] == 0 + assert call_kwargs["json_data"]["rows"] == 25 + + def test_sort_included( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + legacy_document_code_identifier_q="CTNF", sort="submissionDate desc" + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["sort"] == "submissionDate desc" + + def test_no_criteria_no_body_key( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search() + + call_kwargs = mock_get_model.call_args.kwargs + assert "criteria" not in call_kwargs["json_data"] + + def test_additional_query_params_merged_into_body( + self, + client_with_mocked_request: tuple[OARejectionsClient, MagicMock], + mock_response_with_data: OARejectionsResponse, + ) -> None: + client, mock_get_model = client_with_mocked_request + mock_get_model.return_value = mock_response_with_data + + client.search( + legal_section_code_q="103", + additional_query_params={"fl": "id,patentApplicationNumber"}, + ) + + call_kwargs = mock_get_model.call_args.kwargs + assert call_kwargs["json_data"]["fl"] == "id,patentApplicationNumber" + assert "legalSectionCode:103" in call_kwargs["json_data"]["criteria"] + + +# --- TestGetFields --- + + +class TestOARejectionsClientGetFields: + def test_get_fields_calls_correct_endpoint( + self, oa_rejections_client: OARejectionsClient + ) -> None: + mock_fields = OARejectionsFieldsResponse( + api_key="oa_rejections", + api_status="PUBLISHED", + field_count=31, + fields=["patentApplicationNumber", "hasRej101"], + ) + with patch.object( + oa_rejections_client, "_get_model", autospec=True + ) as mock_get_model: + mock_get_model.return_value = mock_fields + result = oa_rejections_client.get_fields() + + mock_get_model.assert_called_once_with( + method="GET", + endpoint="api/v1/patent/oa/oa_rejections/v2/fields", + response_class=OARejectionsFieldsResponse, + ) + assert result is mock_fields + + +# --- TestPaginate --- + + +class TestOARejectionsClientPaginate: + def test_delegates_to_paginate_solr_results( + self, oa_rejections_client: OARejectionsClient + ) -> None: + with patch.object( + oa_rejections_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + oa_rejections_client.paginate(legacy_document_code_identifier_q="CTNF", rows=10) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=None, + legacy_document_code_identifier_q="CTNF", + rows=10, + ) + + def test_passes_post_body(self, oa_rejections_client: OARejectionsClient) -> None: + with patch.object( + oa_rejections_client, "paginate_solr_results", autospec=True + ) as mock_paginate: + mock_paginate.return_value = iter([]) + post_body = {"criteria": "hasRej103:1", "rows": 50} + oa_rejections_client.paginate(post_body=post_body) + + mock_paginate.assert_called_once_with( + method_name="search", + response_container_attr="docs", + post_body=post_body, + ) + + def test_yields_records( + self, + oa_rejections_client: OARejectionsClient, + mock_record: OARejectionsRecord, + mock_response_with_data: OARejectionsResponse, + ) -> None: + with patch.object( + oa_rejections_client, "search", autospec=True + ) as mock_search: + mock_search.side_effect = [ + mock_response_with_data, + OARejectionsResponse(num_found=0, start=0, docs=[]), + ] + results = list( + oa_rejections_client.paginate(legacy_document_code_identifier_q="CTNF") + ) + + assert len(results) == 1 + assert results[0] is mock_record diff --git a/tests/integration/test_oa_actions_integration.py b/tests/integration/test_oa_actions_integration.py new file mode 100644 index 0000000..fc92ce4 --- /dev/null +++ b/tests/integration/test_oa_actions_integration.py @@ -0,0 +1,237 @@ +"""Integration tests for the USPTO OA Actions API client. + +This module contains integration tests that make real API calls to the USPTO +Office Action Text Retrieval API. These tests are skipped by default unless +the ENABLE_INTEGRATION_TESTS environment variable is set to 'true'. +""" + +import os + +import pytest + +from pyUSPTO.clients import OAActionsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, + OAActionsSection, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("ENABLE_INTEGRATION_TESTS", "").lower() != "true", + reason="Integration tests are disabled. Set ENABLE_INTEGRATION_TESTS=true to enable.", +) + +# Known stable record used for exact-value assertions: +# patentApplicationNumber: 11363598 +# id: 9c27199b54dc83c9a6f643b828990d0322071461557b31ead3428885 +_KNOWN_ID = "9c27199b54dc83c9a6f643b828990d0322071461557b31ead3428885" +_KNOWN_APP_NUMBER = "11363598" + + +@pytest.fixture(scope="module") +def oa_actions_client(config: USPTOConfig) -> OAActionsClient: + """Create an OAActionsClient instance for integration tests.""" + return OAActionsClient(config=config) + + +class TestOAActionsSearch: + """Integration tests for search.""" + + def test_search_returns_results( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search(criteria="*:*", rows=5) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + assert len(response.docs) == 5 + + def test_search_by_application_number( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + patent_application_number_q=_KNOWN_APP_NUMBER + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + assert len(response.docs) > 0 + for doc in response.docs: + assert _KNOWN_APP_NUMBER in doc.patent_application_number + + def test_search_by_id_exact_values( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + criteria=f'id:"{_KNOWN_ID}"', + rows=1, + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found == 1 + assert len(response.docs) == 1 + + doc = response.docs[0] + assert doc.id == _KNOWN_ID + assert doc.patent_application_number == [_KNOWN_APP_NUMBER] + assert doc.group_art_unit_number == 2889 + assert doc.tech_center == ["2800"] + assert doc.patent_number == ["7786673"] + assert doc.legacy_document_code_identifier == ["CTNF"] + assert doc.application_status_number == 250 + assert doc.customer_number == 86378 + assert doc.patent_application_confirmation_number == 6020 + assert doc.invention_title == ["GAS-FILLED SHROUD TO PROVIDE COOLER ARCTUBE"] + assert doc.access_level_category == ["PUBLIC"] + assert doc.application_type_category == ["REGULAR"] + assert doc.source_system_name == ["OACS"] + assert doc.submission_date is not None + assert doc.submission_date.year == 2010 + assert doc.submission_date.month == 2 + assert doc.submission_date.day == 19 + assert doc.grant_date is not None + assert doc.grant_date.year == 2010 + assert doc.grant_date.month == 8 + assert doc.grant_date.day == 31 + assert doc.filing_date is not None + assert doc.filing_date.year == 2006 + assert doc.filing_date.month == 2 + assert doc.filing_date.day == 28 + + def test_search_by_id_sections_populated( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + criteria=f'id:"{_KNOWN_ID}"', + rows=1, + ) + doc = response.docs[0] + assert doc.section is not None + assert isinstance(doc.section, OAActionsSection) + assert doc.section.patent_application_number == [_KNOWN_APP_NUMBER] + assert doc.section.group_art_unit_number == ["2889"] + assert doc.section.tech_center_number == ["2800"] + assert doc.section.legacy_document_code_identifier == ["CTNF"] + assert doc.section.obsolete_document_identifier == ["G5SCPRI8PPOPPY5"] + assert len(doc.section.section_102_rejection_text) > 0 + assert doc.section.section_102_rejection_text[0].startswith( + "the following is a quotation" + ) + assert doc.section.submission_date is not None + assert doc.section.submission_date.year == 2010 + assert doc.section.grant_date is not None + assert doc.section.grant_date.year == 2010 + + def test_search_by_legacy_doc_code( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + legacy_document_code_identifier_q="CTNF", + rows=5, + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert "CTNF" in doc.legacy_document_code_identifier + + def test_search_by_tech_center( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search(tech_center_q="2800", rows=5) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert "2800" in doc.tech_center + + def test_search_by_submission_date_range( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + submission_date_from_q="2010-01-01", + submission_date_to_q="2010-12-31", + rows=5, + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.submission_date is not None + assert doc.submission_date.year == 2010 + + def test_search_combined_params( + self, oa_actions_client: OAActionsClient + ) -> None: + response = oa_actions_client.search( + tech_center_q="2800", + legacy_document_code_identifier_q="CTNF", + rows=5, + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert "2800" in doc.tech_center + assert "CTNF" in doc.legacy_document_code_identifier + + def test_search_with_sort(self, oa_actions_client: OAActionsClient) -> None: + response = oa_actions_client.search( + tech_center_q="1700", + sort="submissionDate desc", + rows=5, + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + assert len(response.docs) <= 5 + + def test_search_direct_query(self, oa_actions_client: OAActionsClient) -> None: + response = oa_actions_client.search( + criteria=f"patentApplicationNumber:{_KNOWN_APP_NUMBER}" + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert _KNOWN_APP_NUMBER in doc.patent_application_number + + def test_search_post_body(self, oa_actions_client: OAActionsClient) -> None: + response = oa_actions_client.search( + post_body={ + "criteria": f"patentApplicationNumber:{_KNOWN_APP_NUMBER}", + "rows": 5, + } + ) + assert isinstance(response, OAActionsResponse) + assert response.num_found > 0 + + +class TestOAActionsGetFields: + """Integration tests for get_fields.""" + + def test_get_fields(self, oa_actions_client: OAActionsClient) -> None: + response = oa_actions_client.get_fields() + assert isinstance(response, OAActionsFieldsResponse) + assert response.api_status == "PUBLISHED" + assert response.field_count == 56 + assert len(response.fields) == 56 + assert "patentApplicationNumber" in response.fields + assert "bodyText" in response.fields + assert "submissionDate" in response.fields + assert "legacyDocumentCodeIdentifier" in response.fields + assert "sections.section102RejectionText" in response.fields + assert "sections.groupArtUnitNumber" in response.fields + + +class TestOAActionsPaginate: + """Integration tests for paginate.""" + + def test_paginate_yields_records( + self, oa_actions_client: OAActionsClient + ) -> None: + count = 0 + for record in oa_actions_client.paginate( + tech_center_q="1700", + rows=10, + ): + assert isinstance(record, OAActionsRecord) + assert "1700" in record.tech_center + count += 1 + if count >= 25: + break + + assert count == 25 diff --git a/tests/integration/test_oa_citations_integration.py b/tests/integration/test_oa_citations_integration.py new file mode 100644 index 0000000..b3c10ba --- /dev/null +++ b/tests/integration/test_oa_citations_integration.py @@ -0,0 +1,234 @@ +"""Integration tests for the USPTO OA Citations API client. + +This module contains integration tests that make real API calls to the USPTO +Office Action Citations API. These tests are skipped by default unless +the ENABLE_INTEGRATION_TESTS environment variable is set to 'true'. +""" + +import os + +import pytest + +from pyUSPTO.clients import OACitationsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("ENABLE_INTEGRATION_TESTS", "").lower() != "true", + reason="Integration tests are disabled. Set ENABLE_INTEGRATION_TESTS=true to enable.", +) + +# Known stable record used for exact-value assertions: +# patentApplicationNumber: 17519936 +# id: 90d4b51ab322a638b1327494a7129975 +_KNOWN_ID = "90d4b51ab322a638b1327494a7129975" +_KNOWN_APP_NUMBER = "17519936" + + +@pytest.fixture(scope="module") +def oa_citations_client(config: USPTOConfig) -> OACitationsClient: + """Create an OACitationsClient instance for integration tests.""" + return OACitationsClient(config=config) + + +class TestOACitationsSearch: + """Integration tests for search.""" + + def test_search_returns_results( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search(criteria="*:*", rows=5) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + assert len(response.docs) == 5 + + def test_search_by_application_number( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + patent_application_number_q=_KNOWN_APP_NUMBER + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + assert len(response.docs) > 0 + for doc in response.docs: + assert doc.patent_application_number == _KNOWN_APP_NUMBER + + def test_search_by_id_exact_values( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + criteria=f'id:"{_KNOWN_ID}"', + rows=1, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found == 1 + assert len(response.docs) == 1 + + doc = response.docs[0] + assert doc.id == _KNOWN_ID + assert doc.patent_application_number == _KNOWN_APP_NUMBER + assert doc.action_type_category == "rejected" + assert doc.legal_section_code == "103" + assert doc.group_art_unit_number == "2858" + assert doc.work_group == "2850" + assert doc.tech_center == "2800" + assert doc.examiner_cited_reference_indicator is True + assert doc.applicant_cited_examiner_reference_indicator is False + assert doc.office_action_citation_reference_indicator is True + assert doc.reference_identifier == "Itagaki; Takeshi US 20150044531 A1 " + assert doc.parsed_reference_identifier == "20150044531" + assert doc.obsolete_document_identifier == "LD1Q0FKGXBLUEX4" + assert doc.create_user_identifier == "ETL_SYS" + assert doc.create_date_time is not None + assert doc.create_date_time.year == 2025 + assert doc.create_date_time.month == 7 + assert doc.create_date_time.day == 3 + + def test_search_by_legal_section_code( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + legal_section_code_q="103", + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert "103" in doc.legal_section_code + + def test_search_by_tech_center( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search(tech_center_q="2800", rows=5) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.tech_center == "2800" + + def test_search_by_action_type_category( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + action_type_category_q="rejected", + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.action_type_category == "rejected" + + def test_search_by_examiner_cited( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + examiner_cited_reference_indicator_q=True, + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.examiner_cited_reference_indicator is True + + def test_search_by_date_range( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + create_date_time_from_q="2025-07-01", + create_date_time_to_q="2025-07-04", + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.create_date_time is not None + assert doc.create_date_time.year == 2025 + assert doc.create_date_time.month == 7 + + def test_search_combined_params( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + tech_center_q="2800", + legal_section_code_q="103", + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.tech_center == "2800" + assert "103" in doc.legal_section_code + + def test_search_with_sort( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + tech_center_q="2800", + sort="createDateTime desc", + rows=5, + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + assert len(response.docs) <= 5 + + def test_search_direct_query( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + criteria=f"patentApplicationNumber:{_KNOWN_APP_NUMBER}" + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + for doc in response.docs: + assert doc.patent_application_number == _KNOWN_APP_NUMBER + + def test_search_post_body( + self, oa_citations_client: OACitationsClient + ) -> None: + response = oa_citations_client.search( + post_body={ + "criteria": f"patentApplicationNumber:{_KNOWN_APP_NUMBER}", + "rows": 5, + } + ) + assert isinstance(response, OACitationsResponse) + assert response.num_found > 0 + + +class TestOACitationsGetFields: + """Integration tests for get_fields.""" + + def test_get_fields(self, oa_citations_client: OACitationsClient) -> None: + response = oa_citations_client.get_fields() + assert isinstance(response, OACitationsFieldsResponse) + assert response.api_status == "PUBLISHED" + assert response.field_count == 16 + assert len(response.fields) == 16 + assert "patentApplicationNumber" in response.fields + assert "legalSectionCode" in response.fields + assert "examinerCitedReferenceIndicator" in response.fields + assert "createDateTime" in response.fields + + +class TestOACitationsPaginate: + """Integration tests for paginate.""" + + def test_paginate_yields_records( + self, oa_citations_client: OACitationsClient + ) -> None: + count = 0 + for record in oa_citations_client.paginate( + tech_center_q="2800", + rows=10, + ): + assert isinstance(record, OACitationRecord) + assert record.tech_center == "2800" + count += 1 + if count >= 25: + break + + assert count == 25 diff --git a/tests/integration/test_oa_rejections_integration.py b/tests/integration/test_oa_rejections_integration.py new file mode 100644 index 0000000..43e3afc --- /dev/null +++ b/tests/integration/test_oa_rejections_integration.py @@ -0,0 +1,190 @@ +"""Integration tests for the USPTO OA Rejections API client. + +This module contains integration tests that make real API calls to the USPTO +Office Action Rejections API. These tests are skipped by default unless +the ENABLE_INTEGRATION_TESTS environment variable is set to 'true'. +""" + +import os + +import pytest + +from pyUSPTO.clients import OARejectionsClient +from pyUSPTO.config import USPTOConfig +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("ENABLE_INTEGRATION_TESTS", "").lower() != "true", + reason="Integration tests are disabled. Set ENABLE_INTEGRATION_TESTS=true to enable.", +) + +# Known stable record used for exact-value assertions: +# patentApplicationNumber: 12190351 +# id: 14642e2cc522ac577468fb6fc026d135 +_KNOWN_ID = "14642e2cc522ac577468fb6fc026d135" +_KNOWN_APP_NUMBER = "12190351" + + +@pytest.fixture(scope="module") +def oa_rejections_client(config: USPTOConfig) -> OARejectionsClient: + """Create an OARejectionsClient instance for integration tests.""" + return OARejectionsClient(config=config) + + +class TestOARejectionsSearch: + """Integration tests for search.""" + + def test_search_returns_results( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search(criteria="*:*", rows=5) + assert isinstance(response, OARejectionsResponse) + assert response.num_found > 0 + assert len(response.docs) == 5 + + def test_search_by_application_number( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search( + patent_application_number_q=_KNOWN_APP_NUMBER + ) + assert response.num_found > 0 + assert all( + doc.patent_application_number == _KNOWN_APP_NUMBER for doc in response.docs + ) + + def test_search_by_doc_code( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search( + legacy_document_code_identifier_q="CTNF", rows=5 + ) + assert response.num_found > 0 + assert all( + doc.legacy_document_code_identifier == "CTNF" for doc in response.docs + ) + + def test_search_by_id_exact_values( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search(criteria=f"id:{_KNOWN_ID}", rows=1) + assert response.num_found == 1 + record = response.docs[0] + assert record.id == _KNOWN_ID + assert record.patent_application_number == _KNOWN_APP_NUMBER + assert record.legacy_document_code_identifier == "CTNF" + assert record.group_art_unit_number == "1713" + assert record.legal_section_code == "112" + assert record.national_class == "438" + assert record.national_subclass == "725000" + assert record.obsolete_document_identifier == "GTYKOVWIPPOPPY5" + assert record.create_user_identifier == "ETL_SYS" + assert record.claim_number_array_document == ["1"] + assert record.has_rej_101 is False + assert record.has_rej_102 is False + assert record.has_rej_103 is True + assert record.has_rej_112 is True + assert record.has_rej_dp is False + assert record.bilski_indicator is False + assert record.mayo_indicator is False + assert record.alice_indicator is False + assert record.myriad_indicator is False + assert record.allowed_claim_indicator is False + assert record.cite_103_max == 2 + assert record.cite_103_eq1 == 1 + assert record.cite_103_gt3 == 0 + assert record.closing_missing == 0 + assert record.submission_date is not None + assert record.submission_date.year == 2011 + assert record.submission_date.month == 10 + + def test_search_with_post_body( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search( + post_body={"criteria": f"id:{_KNOWN_ID}", "rows": 1} + ) + assert response.num_found == 1 + assert response.docs[0].id == _KNOWN_ID + + def test_search_date_range( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search( + submission_date_from_q="2011-10-01", + submission_date_to_q="2011-10-31", + rows=5, + ) + assert response.num_found > 0 + + def test_search_pagination_start( + self, oa_rejections_client: OARejectionsClient + ) -> None: + page1 = oa_rejections_client.search( + patent_application_number_q=_KNOWN_APP_NUMBER, start=0, rows=1 + ) + page2 = oa_rejections_client.search( + patent_application_number_q=_KNOWN_APP_NUMBER, start=1, rows=1 + ) + assert page1.num_found == page2.num_found + assert page1.docs[0].id != page2.docs[0].id + + def test_search_count_property( + self, oa_rejections_client: OARejectionsClient + ) -> None: + response = oa_rejections_client.search(criteria="*:*", rows=1) + assert response.count == response.num_found + + +class TestOARejectionsPaginate: + """Integration tests for paginate.""" + + def test_paginate_yields_records( + self, oa_rejections_client: OARejectionsClient + ) -> None: + records = list( + oa_rejections_client.paginate( + patent_application_number_q=_KNOWN_APP_NUMBER + ) + ) + assert len(records) > 0 + assert all(isinstance(r, OARejectionsRecord) for r in records) + + def test_paginate_with_post_body( + self, oa_rejections_client: OARejectionsClient + ) -> None: + records = list( + oa_rejections_client.paginate( + post_body={"criteria": f"id:{_KNOWN_ID}", "rows": 1} + ) + ) + assert len(records) == 1 + assert records[0].id == _KNOWN_ID + + +class TestOARejectionsGetFields: + """Integration tests for get_fields.""" + + def test_get_fields_returns_response( + self, oa_rejections_client: OARejectionsClient + ) -> None: + fields = oa_rejections_client.get_fields() + assert isinstance(fields, OARejectionsFieldsResponse) + + def test_get_fields_exact_values( + self, oa_rejections_client: OARejectionsClient + ) -> None: + fields = oa_rejections_client.get_fields() + assert fields.api_key == "oa_rejections" + assert fields.api_version_number == "v2" + assert fields.api_status == "PUBLISHED" + assert fields.field_count == 31 + assert len(fields.fields) == 31 + assert "patentApplicationNumber" in fields.fields + assert "hasRej101" in fields.fields + assert "hasRej103" in fields.fields + assert "bilskiIndicator" in fields.fields diff --git a/tests/models/test_oa_actions_models.py b/tests/models/test_oa_actions_models.py new file mode 100644 index 0000000..bf71ec5 --- /dev/null +++ b/tests/models/test_oa_actions_models.py @@ -0,0 +1,481 @@ +"""Tests for the oa_actions models. + +This module contains comprehensive tests for all classes in pyUSPTO.models.oa_actions. +""" + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from pyUSPTO.models.oa_actions import ( + OAActionsFieldsResponse, + OAActionsRecord, + OAActionsResponse, + OAActionsSection, +) + + +# --- Fixtures --- + +@pytest.fixture +def sample_record_dict_no_sections() -> dict[str, Any]: + """Sample record without sections fields.""" + return { + "applicationDeemedWithdrawnDate": "0001-01-03T00:00:00", + "workGroup": ["1710"], + "filingDate": "2014-09-12T00:00:00", + "documentActiveIndicator": ["0"], + "legacyDocumentCodeIdentifier": ["NOA"], + "applicationStatusNumber": 150, + "nationalClass": ["427"], + "effectiveFilingDate": "0001-01-03T00:00:00", + "bodyText": ["DETAILED CORRESPONDENCE\nEXAMINER'S AMENDMENT"], + "obsoleteDocumentIdentifier": ["JGW9HEY3RXEAPX3"], + "accessLevelCategory": ["PUBLIC"], + "id": "813869284108aad9fc4821419bb120d78f2a1e69db5a33d77e16f396", + "applicationTypeCategory": ["REGULAR"], + "patentNumber": ["10047236"], + "patentApplicationNumber": ["14485382"], + "grantDate": "2018-08-14T00:00:00", + "submissionDate": "2018-05-09T00:00:00", + "customerNumber": 157106, + "groupArtUnitNumber": 1712, + "inventionTitle": ["METHODS FOR MAKING COPPER INKS AND FILMS"], + "nationalSubclass": ["553000"], + "patentApplicationConfirmationNumber": 5549, + "lastModifiedTimestamp": "2021-01-21T18:45:36", + "examinerEmployeeNumber": ["85150"], + "createDateTime": "2025-01-04T23:00:34", + "techCenter": ["1700"], + "inventionSubjectMatterCategory": ["UTL"], + "sourceSystemName": ["OACS"], + "legacyCMSIdentifier": ["PATENT-14485382-OACS-JGW9HEY3RXEAPX3"], + } + + +@pytest.fixture +def sample_record_dict_with_sections() -> dict[str, Any]: + """Sample record with sections fields.""" + return { + "sections.section101RejectionText": "", + "sections.examinerEmployeeNumber": ["71674"], + "sections.section103RejectionText": [""], + "applicationStatusNumber": 250, + "bodyText": ["\n\n Claim Rejections - 35 USC § 102\n\nThe following..."], + "sections.specificationTitleText": [""], + "sections.grantDate": "2010-08-31T00:00:00", + "accessLevelCategory": ["PUBLIC"], + "id": "9c27199b54dc83c9a6f643b828990d0322071461557b31ead3428885", + "sections.detailCitationText": [""], + "sections.nationalSubclass": ["634000"], + "sections.techCenterNumber": ["2800"], + "sections.patentApplicationNumber": ["11363598"], + "sections.nationalClass": ["313"], + "sections.workGroupNumber": ["2880"], + "patentNumber": ["7786673"], + "patentApplicationNumber": ["11363598"], + "grantDate": "2010-08-31T00:00:00", + "inventionTitle": ["GAS-FILLED SHROUD TO PROVIDE COOLER ARCTUBE"], + "nationalSubclass": ["634000"], + "patentApplicationConfirmationNumber": 6020, + "sections.terminalDisclaimerStatusText": [""], + "sections.groupArtUnitNumber": ["2889"], + "sourceSystemName": ["OACS"], + "sections.filingDate": "2006-02-28T00:00:00", + "sections.proceedingAppendixText": [""], + "sections.withdrawalRejectionText": [""], + "sections.obsoleteDocumentIdentifier": ["G5SCPRI8PPOPPY5"], + "workGroup": ["2880"], + "sections.section102RejectionText": [ + "the following is a quotation of the appropriate paragraphs..." + ], + "filingDate": "2006-02-28T00:00:00", + "documentActiveIndicator": ["0"], + "legacyDocumentCodeIdentifier": ["CTNF"], + "sections.submissionDate": "2010-02-19T00:00:00", + "nationalClass": ["313"], + "effectiveFilingDate": "2006-02-28T00:00:00", + "obsoleteDocumentIdentifier": ["G5SCPRI8PPOPPY5"], + "applicationTypeCategory": ["REGULAR"], + "submissionDate": "2010-02-19T00:00:00", + "customerNumber": 86378, + "groupArtUnitNumber": 2889, + "sections.legacyDocumentCodeIdentifier": ["CTNF"], + "lastModifiedTimestamp": "2024-06-28T16:53:52", + "examinerEmployeeNumber": ["71674"], + "sections.section112RejectionText": [""], + "createDateTime": "2025-01-04T23:00:34", + "techCenter": ["2800"], + "inventionSubjectMatterCategory": ["UTL"], + "sections.summaryText": [""], + "legacyCMSIdentifier": ["dcb1e491-ae2e-4c36-bb45-366ca8eaaf32"], + } + + +@pytest.fixture +def sample_response_dict(sample_record_dict_no_sections: dict[str, Any]) -> dict[str, Any]: + """Sample response dict with the outer Solr envelope.""" + return { + "response": { + "start": 0, + "numFound": 2, + "docs": [ + sample_record_dict_no_sections, + {"id": "abc123", "patentApplicationNumber": ["99999999"]}, + ], + } + } + + +@pytest.fixture +def sample_fields_response_dict() -> dict[str, Any]: + """Sample fields response dict.""" + return { + "apiKey": "oa_actions", + "apiVersionNumber": "v1", + "apiUrl": "https://api.uspto.gov/api/v1/patent/oa/oa_actions/v1/fields", + "apiDocumentationUrl": "https://data.uspto.gov/swagger/index.html", + "apiStatus": "PUBLISHED", + "fieldCount": 56, + "fields": [ + "patentApplicationNumber", + "bodyText", + "submissionDate", + "legacyDocumentCodeIdentifier", + "techCenter", + "groupArtUnitNumber", + ], + "lastDataUpdatedDate": "2020-03-12 11:19:05.0", + } + + +# --- OAActionsSection Tests --- + +class TestOAActionsSectionFromDict: + def test_complete(self, sample_record_dict_with_sections: dict[str, Any]) -> None: + section = OAActionsSection.from_dict(sample_record_dict_with_sections) + assert section.section_101_rejection_text == "" + assert section.examiner_employee_number == ["71674"] + assert section.section_103_rejection_text == [""] + assert section.specification_title_text == [""] + assert section.grant_date is not None + assert section.grant_date.year == 2010 + assert section.grant_date.month == 8 + assert section.grant_date.day == 31 + assert section.national_subclass == ["634000"] + assert section.tech_center_number == ["2800"] + assert section.patent_application_number == ["11363598"] + assert section.national_class == ["313"] + assert section.work_group_number == ["2880"] + assert section.group_art_unit_number == ["2889"] + assert section.filing_date is not None + assert section.filing_date.year == 2006 + assert section.obsolete_document_identifier == ["G5SCPRI8PPOPPY5"] + assert section.section_102_rejection_text == [ + "the following is a quotation of the appropriate paragraphs..." + ] + assert section.legacy_document_code_identifier == ["CTNF"] + assert section.submission_date is not None + assert section.submission_date.year == 2010 + assert section.submission_date.month == 2 + assert section.submission_date.day == 19 + + def test_empty_dict(self) -> None: + section = OAActionsSection.from_dict({}) + assert section.section_101_rejection_text is None + assert section.grant_date is None + assert section.filing_date is None + assert section.submission_date is None + assert section.examiner_employee_number == [] + assert section.section_102_rejection_text == [] + assert section.section_103_rejection_text == [] + assert section.section_112_rejection_text == [] + + def test_defensive_list_handling(self) -> None: + data = { + "sections.examinerEmployeeNumber": "not-a-list", + "sections.section102RejectionText": None, + } + section = OAActionsSection.from_dict(data) + assert section.examiner_employee_number == [] + assert section.section_102_rejection_text == [] + + def test_plain_string_as_list_for_datetime(self) -> None: + data = {"sections.filingDate": ["2006-02-28T00:00:00"]} + section = OAActionsSection.from_dict(data) + assert section.filing_date is not None + assert section.filing_date.year == 2006 + + def test_str_field_as_nonempty_list(self) -> None: + data = {"sections.section101RejectionText": ["some rejection text"]} + section = OAActionsSection.from_dict(data) + assert section.section_101_rejection_text == "some rejection text" + + def test_form_paragraph_fields_absent(self) -> None: + section = OAActionsSection.from_dict({}) + assert section.section_101_rejection_form_paragraph_text == [] + assert section.section_102_rejection_form_paragraph_text == [] + assert section.section_103_rejection_form_paragraph_text == [] + assert section.section_112_rejection_form_paragraph_text == [] + + +class TestOAActionsSectionToDict: + def test_roundtrip(self, sample_record_dict_with_sections: dict[str, Any]) -> None: + section = OAActionsSection.from_dict(sample_record_dict_with_sections) + d = section.to_dict() + assert d["sections.examinerEmployeeNumber"] == ["71674"] + assert d["sections.patentApplicationNumber"] == ["11363598"] + assert d["sections.techCenterNumber"] == ["2800"] + assert d["sections.groupArtUnitNumber"] == ["2889"] + assert d["sections.obsoleteDocumentIdentifier"] == ["G5SCPRI8PPOPPY5"] + assert d["sections.legacyDocumentCodeIdentifier"] == ["CTNF"] + assert d["sections.section102RejectionText"] == [ + "the following is a quotation of the appropriate paragraphs..." + ] + + def test_none_and_empty_list_omitted(self) -> None: + section = OAActionsSection() + d = section.to_dict() + assert d == {} + + def test_datetime_serialized(self) -> None: + data = {"sections.filingDate": "2006-02-28T00:00:00"} + section = OAActionsSection.from_dict(data) + d = section.to_dict() + assert "sections.filingDate" in d + assert "2006" in d["sections.filingDate"] + + +# --- OAActionsRecord Tests --- + +class TestOAActionsRecordFromDict: + def test_complete_no_sections( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + assert record.id == "813869284108aad9fc4821419bb120d78f2a1e69db5a33d77e16f396" + assert record.work_group == ["1710"] + assert record.document_active_indicator == ["0"] + assert record.legacy_document_code_identifier == ["NOA"] + assert record.application_status_number == 150 + assert record.national_class == ["427"] + assert record.body_text == ["DETAILED CORRESPONDENCE\nEXAMINER'S AMENDMENT"] + assert record.obsolete_document_identifier == ["JGW9HEY3RXEAPX3"] + assert record.access_level_category == ["PUBLIC"] + assert record.application_type_category == ["REGULAR"] + assert record.patent_number == ["10047236"] + assert record.patent_application_number == ["14485382"] + assert record.customer_number == 157106 + assert record.group_art_unit_number == 1712 + assert record.invention_title == ["METHODS FOR MAKING COPPER INKS AND FILMS"] + assert record.national_subclass == ["553000"] + assert record.patent_application_confirmation_number == 5549 + assert record.examiner_employee_number == ["85150"] + assert record.tech_center == ["1700"] + assert record.invention_subject_matter_category == ["UTL"] + assert record.source_system_name == ["OACS"] + assert record.legacy_cms_identifier == ["PATENT-14485382-OACS-JGW9HEY3RXEAPX3"] + assert record.section is None + + def test_filing_date_parsed( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + assert record.filing_date is not None + assert record.filing_date.year == 2014 + assert record.filing_date.month == 9 + assert record.filing_date.day == 12 + + def test_submission_date_parsed( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + assert record.submission_date is not None + assert record.submission_date.year == 2018 + assert record.submission_date.month == 5 + assert record.submission_date.day == 9 + + def test_grant_date_parsed( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + assert record.grant_date is not None + assert record.grant_date.year == 2018 + assert record.grant_date.month == 8 + + def test_patent_number_null_string_filtered(self) -> None: + record = OAActionsRecord.from_dict({"patentNumber": ["null"]}) + assert record.patent_number == [] + + def test_patent_number_mixed_null_filtered(self) -> None: + record = OAActionsRecord.from_dict( + {"patentNumber": ["10047236", "null", "9999999"]} + ) + assert record.patent_number == ["10047236", "9999999"] + + def test_with_sections( + self, sample_record_dict_with_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_with_sections) + assert record.section is not None + assert isinstance(record.section, OAActionsSection) + assert record.section.patent_application_number == ["11363598"] + assert record.section.tech_center_number == ["2800"] + + def test_no_sections_when_no_sections_keys( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + assert record.section is None + + def test_empty_dict(self) -> None: + record = OAActionsRecord.from_dict({}) + assert record.id == "" + assert record.patent_number == [] + assert record.work_group == [] + assert record.body_text == [] + assert record.section is None + assert record.group_art_unit_number is None + assert record.customer_number is None + + def test_defensive_list_handling(self) -> None: + record = OAActionsRecord.from_dict( + {"workGroup": "not-a-list", "bodyText": None} + ) + assert record.work_group == [] + assert record.body_text == [] + + +class TestOAActionsRecordToDict: + def test_roundtrip_no_sections( + self, sample_record_dict_no_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_no_sections) + d = record.to_dict() + assert d["id"] == "813869284108aad9fc4821419bb120d78f2a1e69db5a33d77e16f396" + assert d["patentApplicationNumber"] == ["14485382"] + assert d["patentNumber"] == ["10047236"] + assert d["groupArtUnitNumber"] == 1712 + assert d["applicationStatusNumber"] == 150 + assert d["customerNumber"] == 157106 + assert "sections.examinerEmployeeNumber" not in d + + def test_roundtrip_with_sections( + self, sample_record_dict_with_sections: dict[str, Any] + ) -> None: + record = OAActionsRecord.from_dict(sample_record_dict_with_sections) + d = record.to_dict() + assert d["id"] == "9c27199b54dc83c9a6f643b828990d0322071461557b31ead3428885" + assert d["patentApplicationNumber"] == ["11363598"] + assert "sections.examinerEmployeeNumber" in d + assert d["sections.examinerEmployeeNumber"] == ["71674"] + assert d["sections.techCenterNumber"] == ["2800"] + + def test_none_and_empty_list_omitted(self) -> None: + record = OAActionsRecord.from_dict({}) + d = record.to_dict() + assert "filingDate" not in d + assert "workGroup" not in d + assert "patentNumber" not in d + + +# --- OAActionsResponse Tests --- + +class TestOAActionsResponseFromDict: + def test_complete(self, sample_response_dict: dict[str, Any]) -> None: + response = OAActionsResponse.from_dict(sample_response_dict) + assert response.num_found == 2 + assert response.start == 0 + assert len(response.docs) == 2 + assert response.raw_data is None + + def test_count_property(self, sample_response_dict: dict[str, Any]) -> None: + response = OAActionsResponse.from_dict(sample_response_dict) + assert response.count == 2 + assert response.count == response.num_found + + def test_raw_data_included(self, sample_response_dict: dict[str, Any]) -> None: + response = OAActionsResponse.from_dict( + sample_response_dict, include_raw_data=True + ) + assert response.raw_data is not None + assert '"numFound"' in response.raw_data + + def test_empty_response(self) -> None: + response = OAActionsResponse.from_dict( + {"response": {"start": 0, "numFound": 0, "docs": []}} + ) + assert response.num_found == 0 + assert response.docs == [] + + def test_unwrapped_dict(self) -> None: + response = OAActionsResponse.from_dict( + {"start": 0, "numFound": 1, "docs": [{"id": "abc"}]} + ) + assert response.num_found == 1 + assert len(response.docs) == 1 + + def test_docs_non_dict_skipped(self) -> None: + response = OAActionsResponse.from_dict( + {"response": {"start": 0, "numFound": 3, "docs": [{"id": "a"}, "bad", None]}} + ) + assert len(response.docs) == 1 + + def test_docs_not_list(self) -> None: + response = OAActionsResponse.from_dict( + {"response": {"start": 0, "numFound": 0, "docs": None}} + ) + assert response.docs == [] + + +class TestOAActionsResponseToDict: + def test_roundtrip(self, sample_response_dict: dict[str, Any]) -> None: + response = OAActionsResponse.from_dict(sample_response_dict) + d = response.to_dict() + assert "response" in d + assert d["response"]["numFound"] == 2 + assert d["response"]["start"] == 0 + assert len(d["response"]["docs"]) == 2 + + +# --- OAActionsFieldsResponse Tests --- + +class TestOAActionsFieldsResponseFromDict: + def test_complete(self, sample_fields_response_dict: dict[str, Any]) -> None: + response = OAActionsFieldsResponse.from_dict(sample_fields_response_dict) + assert response.api_key == "oa_actions" + assert response.api_version_number == "v1" + assert response.api_status == "PUBLISHED" + assert response.field_count == 56 + assert len(response.fields) == 6 + assert "patentApplicationNumber" in response.fields + assert "bodyText" in response.fields + assert response.last_data_updated_date == "2020-03-12 11:19:05.0" + + def test_empty(self) -> None: + response = OAActionsFieldsResponse.from_dict({}) + assert response.api_key is None + assert response.api_status is None + assert response.field_count == 0 + assert response.fields == [] + + def test_fields_not_list(self) -> None: + response = OAActionsFieldsResponse.from_dict({"fields": "not-a-list"}) + assert response.fields == [] + + +class TestOAActionsFieldsResponseToDict: + def test_roundtrip(self, sample_fields_response_dict: dict[str, Any]) -> None: + response = OAActionsFieldsResponse.from_dict(sample_fields_response_dict) + d = response.to_dict() + assert d["apiKey"] == "oa_actions" + assert d["apiStatus"] == "PUBLISHED" + assert d["fieldCount"] == 56 + assert "patentApplicationNumber" in d["fields"] + + def test_none_omitted(self) -> None: + response = OAActionsFieldsResponse.from_dict({}) + d = response.to_dict() + assert "apiKey" not in d + assert "apiStatus" not in d diff --git a/tests/models/test_oa_citations_models.py b/tests/models/test_oa_citations_models.py new file mode 100644 index 0000000..bf43040 --- /dev/null +++ b/tests/models/test_oa_citations_models.py @@ -0,0 +1,349 @@ +"""Tests for the oa_citations models. + +This module contains comprehensive tests for all classes in pyUSPTO.models.oa_citations. +""" + +import warnings +from typing import Any + +import pytest + +from pyUSPTO.models.oa_citations import ( + OACitationRecord, + OACitationsFieldsResponse, + OACitationsResponse, +) +from pyUSPTO.warnings import USPTODateParseWarning + + +# --- Fixtures --- + + +@pytest.fixture +def sample_record_dict() -> dict[str, Any]: + """Sample record from real API data.""" + return { + "applicantCitedExaminerReferenceIndicator": False, + "createUserIdentifier": "ETL_SYS", + "workGroup": "2850", + "officeActionCitationReferenceIndicator": True, + "referenceIdentifier": "Itagaki; Takeshi US 20150044531 A1 ", + "patentApplicationNumber": "17519936", + "actionTypeCategory": "rejected", + "legalSectionCode": "103", + "groupArtUnitNumber": "2858", + "createDateTime": "2025-07-03T13:51:37", + "techCenter": "2800", + "obsoleteDocumentIdentifier": "LD1Q0FKGXBLUEX4", + "parsedReferenceIdentifier": "20150044531", + "id": "90d4b51ab322a638b1327494a7129975", + "examinerCitedReferenceIndicator": True, + } + + +@pytest.fixture +def sample_record_dict_minimal() -> dict[str, Any]: + """Sample record with mostly empty/default values.""" + return { + "applicantCitedExaminerReferenceIndicator": False, + "createUserIdentifier": "ETL_SYS", + "workGroup": "3660", + "officeActionCitationReferenceIndicator": False, + "referenceIdentifier": "", + "patentApplicationNumber": "16845502", + "actionTypeCategory": "rejected", + "legalSectionCode": "", + "groupArtUnitNumber": "3663", + "createDateTime": "2025-07-04T20:23:09", + "techCenter": "3600", + "obsoleteDocumentIdentifier": "KQ6WYGGVLDFLYX4", + "parsedReferenceIdentifier": "", + "id": "ba27780c738055eed0332b28b78ef6d6", + "examinerCitedReferenceIndicator": False, + } + + +@pytest.fixture +def sample_response_dict(sample_record_dict: dict[str, Any]) -> dict[str, Any]: + """Sample full API response envelope.""" + return { + "response": { + "start": 0, + "numFound": 133157634, + "docs": [sample_record_dict], + } + } + + +@pytest.fixture +def sample_fields_response_dict() -> dict[str, Any]: + """Sample fields endpoint response.""" + return { + "apiKey": "oa_citations", + "apiVersionNumber": "v2", + "apiUrl": "https://api.uspto.gov/api/v1/patent/oa/oa_citations/v2/fields", + "apiDocumentationUrl": "arn:aws:iam::831714700926:role/uspto-dev/uspto-dh-p-prod-service-role-1", + "apiStatus": "PUBLISHED", + "fieldCount": 16, + "fields": [ + "applicantCitedExaminerReferenceIndicator", + "createUserIdentifier", + "workGroup", + "officeActionCitationReferenceIndicator", + "referenceIdentifier", + "actionTypeCategory", + "patentApplicationNumber", + "legalSectionCode", + "groupArtUnitNumber", + "createDateTime", + "techCenter", + "paragraphNumber", + "obsoleteDocumentIdentifier", + "parsedReferenceIdentifier", + "id", + "examinerCitedReferenceIndicator", + ], + "lastDataUpdatedDate": "2019-06-26 11:20:17.0", + } + + +# --- TestOACitationRecordFromDict --- + + +class TestOACitationRecordFromDict: + def test_complete_record(self, sample_record_dict: dict[str, Any]) -> None: + record = OACitationRecord.from_dict(sample_record_dict) + assert record.id == "90d4b51ab322a638b1327494a7129975" + assert record.patent_application_number == "17519936" + assert record.action_type_category == "rejected" + assert record.legal_section_code == "103" + assert record.reference_identifier == "Itagaki; Takeshi US 20150044531 A1 " + assert record.parsed_reference_identifier == "20150044531" + assert record.group_art_unit_number == "2858" + assert record.work_group == "2850" + assert record.tech_center == "2800" + assert record.applicant_cited_examiner_reference_indicator is False + assert record.examiner_cited_reference_indicator is True + assert record.office_action_citation_reference_indicator is True + assert record.create_user_identifier == "ETL_SYS" + assert record.obsolete_document_identifier == "LD1Q0FKGXBLUEX4" + assert record.create_date_time is not None + assert record.create_date_time.year == 2025 + assert record.create_date_time.month == 7 + assert record.create_date_time.day == 3 + + def test_minimal_record( + self, sample_record_dict_minimal: dict[str, Any] + ) -> None: + record = OACitationRecord.from_dict(sample_record_dict_minimal) + assert record.id == "ba27780c738055eed0332b28b78ef6d6" + assert record.patent_application_number == "16845502" + assert record.action_type_category == "rejected" + assert record.legal_section_code == "" + assert record.reference_identifier == "" + assert record.parsed_reference_identifier == "" + assert record.examiner_cited_reference_indicator is False + assert record.office_action_citation_reference_indicator is False + + def test_empty_dict(self) -> None: + record = OACitationRecord.from_dict({}) + assert record.id == "" + assert record.patent_application_number == "" + assert record.action_type_category == "" + assert record.legal_section_code == "" + assert record.reference_identifier == "" + assert record.parsed_reference_identifier == "" + assert record.group_art_unit_number == "" + assert record.work_group == "" + assert record.tech_center == "" + assert record.paragraph_number == "" + assert record.applicant_cited_examiner_reference_indicator is None + assert record.examiner_cited_reference_indicator is None + assert record.office_action_citation_reference_indicator is None + assert record.create_user_identifier == "" + assert record.create_date_time is None + assert record.obsolete_document_identifier == "" + + def test_bad_datetime(self) -> None: + data = {"createDateTime": "not-a-date"} + with warnings.catch_warnings(): + warnings.simplefilter("ignore", USPTODateParseWarning) + record = OACitationRecord.from_dict(data) + assert record.create_date_time is None + + +# --- TestOACitationRecordToDict --- + + +class TestOACitationRecordToDict: + def test_roundtrip(self, sample_record_dict: dict[str, Any]) -> None: + record = OACitationRecord.from_dict(sample_record_dict) + result = record.to_dict() + assert result["id"] == "90d4b51ab322a638b1327494a7129975" + assert result["patentApplicationNumber"] == "17519936" + assert result["actionTypeCategory"] == "rejected" + assert result["legalSectionCode"] == "103" + assert result["examinerCitedReferenceIndicator"] is True + assert result["officeActionCitationReferenceIndicator"] is True + assert result["applicantCitedExaminerReferenceIndicator"] is False + assert "createDateTime" in result + + def test_none_filtering(self) -> None: + record = OACitationRecord(id="test123") + result = record.to_dict() + assert result["id"] == "test123" + assert "applicantCitedExaminerReferenceIndicator" not in result + assert "examinerCitedReferenceIndicator" not in result + assert "officeActionCitationReferenceIndicator" not in result + assert "createDateTime" not in result + + +# --- TestOACitationsResponseFromDict --- + + +class TestOACitationsResponseFromDict: + def test_complete_response( + self, sample_response_dict: dict[str, Any] + ) -> None: + response = OACitationsResponse.from_dict(sample_response_dict) + assert response.num_found == 133157634 + assert response.start == 0 + assert len(response.docs) == 1 + assert response.docs[0].id == "90d4b51ab322a638b1327494a7129975" + + def test_count_property( + self, sample_response_dict: dict[str, Any] + ) -> None: + response = OACitationsResponse.from_dict(sample_response_dict) + assert response.count == 133157634 + assert response.count == response.num_found + + def test_empty_response(self) -> None: + response = OACitationsResponse.from_dict( + {"response": {"start": 0, "numFound": 0, "docs": []}} + ) + assert response.num_found == 0 + assert response.start == 0 + assert response.docs == [] + + def test_multiple_records( + self, sample_record_dict: dict[str, Any], + sample_record_dict_minimal: dict[str, Any], + ) -> None: + data = { + "response": { + "start": 0, + "numFound": 2, + "docs": [sample_record_dict, sample_record_dict_minimal], + } + } + response = OACitationsResponse.from_dict(data) + assert len(response.docs) == 2 + assert response.docs[0].id == "90d4b51ab322a638b1327494a7129975" + assert response.docs[1].id == "ba27780c738055eed0332b28b78ef6d6" + + def test_raw_data_toggle( + self, sample_response_dict: dict[str, Any] + ) -> None: + response_no_raw = OACitationsResponse.from_dict( + sample_response_dict, include_raw_data=False + ) + assert response_no_raw.raw_data is None + + response_with_raw = OACitationsResponse.from_dict( + sample_response_dict, include_raw_data=True + ) + assert response_with_raw.raw_data is not None + assert "90d4b51ab322a638b1327494a7129975" in response_with_raw.raw_data + + def test_pre_unwrapped_dict(self) -> None: + inner = {"start": 5, "numFound": 10, "docs": []} + response = OACitationsResponse.from_dict(inner) + assert response.num_found == 10 + assert response.start == 5 + + def test_non_list_docs(self) -> None: + data = {"response": {"start": 0, "numFound": 0, "docs": "not-a-list"}} + response = OACitationsResponse.from_dict(data) + assert response.docs == [] + + def test_non_dict_doc_items_filtered(self) -> None: + data = { + "response": { + "start": 0, + "numFound": 1, + "docs": ["not-a-dict", 42, None], + } + } + response = OACitationsResponse.from_dict(data) + assert response.docs == [] + + +# --- TestOACitationsResponseToDict --- + + +class TestOACitationsResponseToDict: + def test_roundtrip( + self, sample_response_dict: dict[str, Any] + ) -> None: + response = OACitationsResponse.from_dict(sample_response_dict) + result = response.to_dict() + assert "response" in result + assert result["response"]["numFound"] == 133157634 + assert result["response"]["start"] == 0 + assert len(result["response"]["docs"]) == 1 + + +# --- TestOACitationsFieldsResponseFromDict --- + + +class TestOACitationsFieldsResponseFromDict: + def test_complete_fields_response( + self, sample_fields_response_dict: dict[str, Any] + ) -> None: + response = OACitationsFieldsResponse.from_dict(sample_fields_response_dict) + assert response.api_key == "oa_citations" + assert response.api_version_number == "v2" + assert response.api_status == "PUBLISHED" + assert response.field_count == 16 + assert len(response.fields) == 16 + assert "patentApplicationNumber" in response.fields + assert "examinerCitedReferenceIndicator" in response.fields + assert response.last_data_updated_date == "2019-06-26 11:20:17.0" + + def test_empty_dict(self) -> None: + response = OACitationsFieldsResponse.from_dict({}) + assert response.api_key is None + assert response.api_version_number is None + assert response.api_status is None + assert response.field_count == 0 + assert response.fields == [] + + def test_non_list_fields(self) -> None: + response = OACitationsFieldsResponse.from_dict({"fields": "not-a-list"}) + assert response.fields == [] + + +# --- TestOACitationsFieldsResponseToDict --- + + +class TestOACitationsFieldsResponseToDict: + def test_roundtrip( + self, sample_fields_response_dict: dict[str, Any] + ) -> None: + response = OACitationsFieldsResponse.from_dict(sample_fields_response_dict) + result = response.to_dict() + assert result["apiKey"] == "oa_citations" + assert result["apiVersionNumber"] == "v2" + assert result["apiStatus"] == "PUBLISHED" + assert result["fieldCount"] == 16 + assert len(result["fields"]) == 16 + + def test_none_filtering(self) -> None: + response = OACitationsFieldsResponse(field_count=0) + result = response.to_dict() + assert "apiKey" not in result + assert "apiVersionNumber" not in result + assert "apiStatus" not in result + assert "lastDataUpdatedDate" not in result + assert result["fieldCount"] == 0 diff --git a/tests/models/test_oa_rejections_models.py b/tests/models/test_oa_rejections_models.py new file mode 100644 index 0000000..14c5f19 --- /dev/null +++ b/tests/models/test_oa_rejections_models.py @@ -0,0 +1,431 @@ +"""Tests for the pyUSPTO.models.oa_rejections module. + +This module contains comprehensive tests for OARejectionsRecord, +OARejectionsResponse, and OARejectionsFieldsResponse. +""" + +from datetime import datetime +from typing import Any + +import pytest + +from pyUSPTO.models.oa_rejections import ( + OARejectionsFieldsResponse, + OARejectionsRecord, + OARejectionsResponse, +) + +# --- Fixtures --- + + +@pytest.fixture +def sample_record_dict() -> dict[str, Any]: + """Real record from the OA Rejections API (app 12190351).""" + return { + "bilskiIndicator": False, + "actionTypeCategory": "", + "legacyDocumentCodeIdentifier": "CTNF", + "hasRej101": 0, + "hasRejDP": 0, + "hasRej103": 1, + "mayoIndicator": False, + "hasRej102": 0, + "nationalClass": "438", + "closingMissing": 0, + "cite103Max": 2, + "cite103EQ1": 1, + "obsoleteDocumentIdentifier": "GTYKOVWIPPOPPY5", + "id": "14642e2cc522ac577468fb6fc026d135", + "createUserIdentifier": "ETL_SYS", + "claimNumberArrayDocument": ["1"], + "patentApplicationNumber": "12190351", + "legalSectionCode": "112", + "submissionDate": "2011-10-19T00:00:00", + "groupArtUnitNumber": "1713", + "hasRej112": 1, + "nationalSubclass": "725000", + "rejectFormMissmatch": 0, + "createDateTime": "2025-07-15T21:22:06", + "formParagraphMissing": 0, + "aliceIndicator": False, + "allowedClaimIndicator": False, + "paragraphNumber": "", + "cite103GT3": 0, + "myriadIndicator": False, + "headerMissing": 0, + } + + +@pytest.fixture +def sample_comma_claims_dict() -> dict[str, Any]: + """Real record with comma-separated claim numbers (app 12190351, record 2).""" + return { + "id": "ed812f618d3a72142850669b6b608ac3", + "patentApplicationNumber": "12190351", + "legacyDocumentCodeIdentifier": "CTNF", + "claimNumberArrayDocument": ["1,2,3,4,5"], + "hasRej103": 1, + "hasRej112": 1, + "hasRej101": 0, + "hasRej102": 0, + "hasRejDP": 0, + "bilskiIndicator": False, + "mayoIndicator": False, + "aliceIndicator": False, + "myriadIndicator": False, + "allowedClaimIndicator": False, + "groupArtUnitNumber": "1713", + "legalSectionCode": "", + "submissionDate": "2011-10-19T00:00:00", + "createDateTime": "2025-07-15T21:22:06", + "nationalClass": "438", + "nationalSubclass": "725000", + "cite103Max": 2, + "cite103EQ1": 1, + "cite103GT3": 0, + "closingMissing": 0, + "rejectFormMissmatch": 0, + "formParagraphMissing": 0, + "headerMissing": 0, + "obsoleteDocumentIdentifier": "GTYKOVWIPPOPPY5", + "createUserIdentifier": "ETL_SYS", + "paragraphNumber": "", + "actionTypeCategory": "", + } + + +@pytest.fixture +def sample_response_dict(sample_record_dict: dict[str, Any]) -> dict[str, Any]: + return { + "response": { + "numFound": 86973947, + "start": 0, + "docs": [sample_record_dict], + } + } + + +@pytest.fixture +def sample_fields_dict() -> dict[str, Any]: + return { + "apiKey": "oa_rejections", + "apiVersionNumber": "v2", + "apiUrl": "https://api.uspto.gov/api/v1/patent/oa/oa_rejections/v2/fields", + "apiDocumentationUrl": "https://data.uspto.gov/swagger/index.html", + "apiStatus": "PUBLISHED", + "fieldCount": 31, + "fields": [ + "bilskiIndicator", + "actionTypeCategory", + "legacyDocumentCodeIdentifier", + "hasRej101", + "hasRej103", + "hasRejDP", + "mayoIndicator", + "hasRej102", + "nationalClass", + "closingMissing", + "cite103Max", + "cite103EQ1", + "obsoleteDocumentIdentifier", + "id", + "createUserIdentifier", + "claimNumberArrayDocument", + "patentApplicationNumber", + "legalSectionCode", + "submissionDate", + "groupArtUnitNumber", + "hasRej112", + "nationalSubclass", + "createDateTime", + "rejectFormMissmatch", + "formParagraphMissing", + "aliceIndicator", + "allowedClaimIndicator", + "paragraphNumber", + "cite103GT3", + "myriadIndicator", + "headerMissing", + ], + "lastDataUpdatedDate": "2021-05-26 08:17:45.0", + } + + +# --- TestOARejectionsRecordFromDict --- + + +class TestOARejectionsRecordFromDict: + def test_complete(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert record.id == "14642e2cc522ac577468fb6fc026d135" + assert record.patent_application_number == "12190351" + assert record.legacy_document_code_identifier == "CTNF" + assert record.action_type_category == "" + assert record.legal_section_code == "112" + assert record.group_art_unit_number == "1713" + assert record.national_class == "438" + assert record.national_subclass == "725000" + assert record.paragraph_number == "" + assert record.obsolete_document_identifier == "GTYKOVWIPPOPPY5" + assert record.create_user_identifier == "ETL_SYS" + + def test_rejection_flags(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert record.has_rej_101 is False + assert record.has_rej_102 is False + assert record.has_rej_103 is True + assert record.has_rej_112 is True + assert record.has_rej_dp is False + + def test_boolean_indicators(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert record.bilski_indicator is False + assert record.mayo_indicator is False + assert record.alice_indicator is False + assert record.myriad_indicator is False + assert record.allowed_claim_indicator is False + + def test_int_fields(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert record.cite_103_max == 2 + assert record.cite_103_eq1 == 1 + assert record.cite_103_gt3 == 0 + assert record.closing_missing == 0 + assert record.reject_form_missmatch == 0 + assert record.form_paragraph_missing == 0 + assert record.header_missing == 0 + + def test_single_claim(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert record.claim_number_array_document == ["1"] + + def test_comma_separated_claims( + self, sample_comma_claims_dict: dict[str, Any] + ) -> None: + record = OARejectionsRecord.from_dict(sample_comma_claims_dict) + assert record.claim_number_array_document == ["1", "2", "3", "4", "5"] + + def test_claim_number_not_list(self) -> None: + record = OARejectionsRecord.from_dict( + {"id": "x", "claimNumberArrayDocument": "not-a-list"} + ) + assert record.claim_number_array_document == [] + + def test_claim_number_missing(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x"}) + assert record.claim_number_array_document == [] + + def test_bool_from_int_zero(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x", "hasRej101": 0}) + assert record.has_rej_101 is False + + def test_bool_from_int_one(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x", "hasRej101": 1}) + assert record.has_rej_101 is True + + def test_bool_from_json_bool(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x", "bilskiIndicator": False}) + assert record.bilski_indicator is False + + def test_missing_bool_fields_are_none(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x"}) + assert record.has_rej_101 is None + assert record.bilski_indicator is None + assert record.allowed_claim_indicator is None + + def test_missing_int_fields_are_none(self) -> None: + record = OARejectionsRecord.from_dict({"id": "x"}) + assert record.cite_103_max is None + assert record.closing_missing is None + + def test_submission_date_parsed(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert isinstance(record.submission_date, datetime) + assert record.submission_date.year == 2011 + assert record.submission_date.month == 10 + + def test_create_date_time_parsed( + self, sample_record_dict: dict[str, Any] + ) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + assert isinstance(record.create_date_time, datetime) + assert record.create_date_time.year == 2025 + assert record.create_date_time.month == 7 + + def test_minimal(self) -> None: + record = OARejectionsRecord.from_dict({"id": "abc"}) + assert record.id == "abc" + assert record.patent_application_number is None + assert record.submission_date is None + assert record.claim_number_array_document == [] + + def test_empty_dict(self) -> None: + record = OARejectionsRecord.from_dict({}) + assert record.id == "" + assert record.has_rej_101 is None + + +# --- TestOARejectionsRecordToDict --- + + +class TestOARejectionsRecordToDict: + def test_roundtrip(self, sample_record_dict: dict[str, Any]) -> None: + record = OARejectionsRecord.from_dict(sample_record_dict) + d = record.to_dict() + record2 = OARejectionsRecord.from_dict(d) + assert record == record2 + + def test_none_fields_filtered(self) -> None: + record = OARejectionsRecord(id="x") + d = record.to_dict() + assert "patentApplicationNumber" not in d + assert "legalSectionCode" not in d + assert "submissionDate" not in d + + def test_empty_claims_filtered(self) -> None: + record = OARejectionsRecord(id="x", claim_number_array_document=[]) + d = record.to_dict() + assert "claimNumberArrayDocument" not in d + + def test_claims_joined_to_single_string(self) -> None: + record = OARejectionsRecord( + id="x", claim_number_array_document=["1", "2", "3"] + ) + d = record.to_dict() + assert d["claimNumberArrayDocument"] == ["1,2,3"] + + def test_false_bool_included(self) -> None: + record = OARejectionsRecord(id="x", has_rej_101=False, bilski_indicator=False) + d = record.to_dict() + assert d["hasRej101"] is False + assert d["bilskiIndicator"] is False + + def test_zero_int_included(self) -> None: + record = OARejectionsRecord(id="x", closing_missing=0, cite_103_max=0) + d = record.to_dict() + assert d["closingMissing"] == 0 + assert d["cite103Max"] == 0 + + def test_comma_claims_roundtrip( + self, sample_comma_claims_dict: dict[str, Any] + ) -> None: + record = OARejectionsRecord.from_dict(sample_comma_claims_dict) + assert record.claim_number_array_document == ["1", "2", "3", "4", "5"] + d = record.to_dict() + assert d["claimNumberArrayDocument"] == ["1,2,3,4,5"] + + +# --- TestOARejectionsResponseFromDict --- + + +class TestOARejectionsResponseFromDict: + def test_complete(self, sample_response_dict: dict[str, Any]) -> None: + response = OARejectionsResponse.from_dict(sample_response_dict) + assert response.num_found == 86973947 + assert response.start == 0 + assert len(response.docs) == 1 + assert response.docs[0].id == "14642e2cc522ac577468fb6fc026d135" + + def test_empty(self) -> None: + response = OARejectionsResponse.from_dict( + {"response": {"numFound": 0, "start": 0, "docs": []}} + ) + assert response.num_found == 0 + assert response.docs == [] + + def test_unwrapped_dict(self) -> None: + """from_dict handles a pre-unwrapped dict (no 'response' envelope).""" + response = OARejectionsResponse.from_dict( + {"numFound": 5, "start": 0, "docs": []} + ) + assert response.num_found == 5 + + def test_count_property(self, sample_response_dict: dict[str, Any]) -> None: + response = OARejectionsResponse.from_dict(sample_response_dict) + assert response.count == response.num_found == 86973947 + + def test_raw_data_false(self, sample_response_dict: dict[str, Any]) -> None: + response = OARejectionsResponse.from_dict( + sample_response_dict, include_raw_data=False + ) + assert response.raw_data is None + + def test_raw_data_true(self, sample_response_dict: dict[str, Any]) -> None: + response = OARejectionsResponse.from_dict( + sample_response_dict, include_raw_data=True + ) + assert response.raw_data is not None + assert "86973947" in response.raw_data + + def test_docs_not_list(self) -> None: + response = OARejectionsResponse.from_dict( + {"response": {"numFound": 0, "start": 0, "docs": "bad"}} + ) + assert response.docs == [] + + def test_non_dict_docs_skipped(self) -> None: + response = OARejectionsResponse.from_dict( + {"response": {"numFound": 1, "start": 0, "docs": ["not-a-dict"]}} + ) + assert response.docs == [] + + +# --- TestOARejectionsResponseToDict --- + + +class TestOARejectionsResponseToDict: + def test_roundtrip(self, sample_response_dict: dict[str, Any]) -> None: + response = OARejectionsResponse.from_dict(sample_response_dict) + d = response.to_dict() + assert d["response"]["numFound"] == 86973947 + assert d["response"]["start"] == 0 + assert len(d["response"]["docs"]) == 1 + + def test_empty_roundtrip(self) -> None: + response = OARejectionsResponse(num_found=0, start=0, docs=[]) + d = response.to_dict() + assert d == {"response": {"numFound": 0, "start": 0, "docs": []}} + + +# --- TestOARejectionsFieldsResponseFromDict --- + + +class TestOARejectionsFieldsResponseFromDict: + def test_complete(self, sample_fields_dict: dict[str, Any]) -> None: + fields = OARejectionsFieldsResponse.from_dict(sample_fields_dict) + assert fields.api_key == "oa_rejections" + assert fields.api_version_number == "v2" + assert fields.api_status == "PUBLISHED" + assert fields.field_count == 31 + assert len(fields.fields) == 31 + assert "patentApplicationNumber" in fields.fields + assert "hasRej101" in fields.fields + assert fields.last_data_updated_date == "2021-05-26 08:17:45.0" + + def test_empty(self) -> None: + fields = OARejectionsFieldsResponse.from_dict({}) + assert fields.api_key is None + assert fields.field_count == 0 + assert fields.fields == [] + + def test_fields_not_list_defensive(self) -> None: + fields = OARejectionsFieldsResponse.from_dict({"fields": "bad"}) + assert fields.fields == [] + + +# --- TestOARejectionsFieldsResponseToDict --- + + +class TestOARejectionsFieldsResponseToDict: + def test_roundtrip(self, sample_fields_dict: dict[str, Any]) -> None: + fields = OARejectionsFieldsResponse.from_dict(sample_fields_dict) + d = fields.to_dict() + assert d["apiKey"] == "oa_rejections" + assert d["fieldCount"] == 31 + assert "patentApplicationNumber" in d["fields"] + + def test_none_filtered(self) -> None: + fields = OARejectionsFieldsResponse(field_count=0) + d = fields.to_dict() + assert "apiKey" not in d + assert "apiStatus" not in d diff --git a/tests/test_config.py b/tests/test_config.py index 33925a5..e5d24a5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,6 +17,9 @@ def test_default_values(self): assert config.patent_data_base_url == "https://api.uspto.gov" assert config.petition_decisions_base_url == "https://api.uspto.gov" assert config.enriched_citations_base_url == "https://api.uspto.gov" + assert config.oa_actions_base_url == "https://api.uspto.gov" + assert config.oa_rejections_base_url == "https://api.uspto.gov" + assert config.oa_citations_base_url == "https://api.uspto.gov" assert config.http_config is not None assert isinstance(config.http_config, HTTPConfig) @@ -66,11 +69,17 @@ def test_config_custom_base_urls(self): patent_data_base_url="https://patent.example.com", petition_decisions_base_url="https://petition.example.com", enriched_citations_base_url="https://citations.example.com", + oa_actions_base_url="https://oa.example.com", + oa_rejections_base_url="https://rejections.example.com", + oa_citations_base_url="https://oacitations.example.com", ) assert config.bulk_data_base_url == "https://bulk.example.com" assert config.patent_data_base_url == "https://patent.example.com" assert config.petition_decisions_base_url == "https://petition.example.com" assert config.enriched_citations_base_url == "https://citations.example.com" + assert config.oa_actions_base_url == "https://oa.example.com" + assert config.oa_rejections_base_url == "https://rejections.example.com" + assert config.oa_citations_base_url == "https://oacitations.example.com" def test_config_from_env_custom_urls(self, monkeypatch): """Test USPTOConfig.from_env() reads custom URLs""" @@ -83,12 +92,22 @@ def test_config_from_env_custom_urls(self, monkeypatch): monkeypatch.setenv( "USPTO_ENRICHED_CITATIONS_BASE_URL", "https://citations.example.com" ) + monkeypatch.setenv("USPTO_OA_ACTIONS_BASE_URL", "https://oa.example.com") + monkeypatch.setenv( + "USPTO_OA_REJECTIONS_BASE_URL", "https://rejections.example.com" + ) + monkeypatch.setenv( + "USPTO_OA_CITATIONS_BASE_URL", "https://oacitations.example.com" + ) config = USPTOConfig.from_env() assert config.bulk_data_base_url == "https://bulk.example.com" assert config.patent_data_base_url == "https://patent.example.com" assert config.petition_decisions_base_url == "https://petition.example.com" assert config.enriched_citations_base_url == "https://citations.example.com" + assert config.oa_actions_base_url == "https://oa.example.com" + assert config.oa_rejections_base_url == "https://rejections.example.com" + assert config.oa_citations_base_url == "https://oacitations.example.com" def test_http_config_sharing(self): """Test HTTPConfig can be shared across multiple USPTOConfig instances"""