diff --git a/CHANGELOG.md b/CHANGELOG.md index cff5937..bd0c7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,37 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] + +### Added + +- `USPTODataMismatchWarning` for API data validation +- `sanitize_application_number()` method supporting 8-digit and series code formats +- Optional `include_raw_data` parameter in `USPTOConfig` for debugging +- Content-Disposition header parsing with RFC 2231 support +- `HTTPConfig` class for configurable timeouts, retries, and headers +- `USPTOTimeout` and `USPTOConnectionError` exceptions +- Document type filtering in `get_application_documents()` +- Utility module `models/utils.py` for shared model helpers + +### Changed + +- Response models now support optional `include_raw_data` parameter +- Replaced print statements with Python warnings module +- Refactored base client to use `HTTPConfig` + +## [0.2.0] + ### Added - Full support for USPTO Final Petition Decisions API -- `FinalPetitionDecisionsClient` for interacting with petition decisions -- New data models for petition decisions: - - `PetitionDecision`: Complete petition decision information - - `PetitionDecisionDocument`: Document details and metadata - - `DocumentDownloadOption`: Download options for petition documents - - `PetitionDecisionResponse`: API response wrapper - - `PetitionDecisionDownloadResponse`: Download response wrapper -- Enums for petition decision data: - - `DecisionTypeCode`: Petition decision types - - `DocumentDirectionCategory`: Document direction categories -- Search capabilities with convenience parameters: - - Application number, patent number, technology center - - Decision date ranges, applicant names, inventor names - - Examiner names, decision types, and more -- Pagination support for petition decision searches -- Document download functionality for petition documents -- CSV and JSON export options for petition decisions -- Integration tests for petition decisions (17 tests) -- Unit tests for petition decision models and client (49 tests) -- Example usage file: `examples/petition_decisions_example.py` -- Configuration support for petition decisions base URL in `USPTOConfig` +- `FinalPetitionDecisionsClient` with search, pagination, and document download +- Data models: `PetitionDecision`, `PetitionDecisionDocument`, `PetitionDecisionResponse` +- Enums: `DecisionTypeCode`, `DocumentDirectionCategory` +- CSV and JSON export for petition decisions ## [0.1.2] ### Added -- Initial release of pyUSPTO -- Object Oriented Support for USPTO Patent Data API -- Basic Support for USPTO Bulk Data API -- Full type annotations and docstrings -- Comprehensive test suite +- Initial release +- USPTO Patent Data API support +- USPTO Bulk Data API support diff --git a/src/pyUSPTO/__init__.py b/src/pyUSPTO/__init__.py index fbb294c..0bf8ed6 100644 --- a/src/pyUSPTO/__init__.py +++ b/src/pyUSPTO/__init__.py @@ -39,6 +39,7 @@ ) from pyUSPTO.warnings import ( USPTOBooleanParseWarning, + USPTODataMismatchWarning, USPTODataWarning, USPTODateParseWarning, USPTOEnumParseWarning, @@ -59,6 +60,7 @@ "USPTOBooleanParseWarning", "USPTOTimezoneWarning", "USPTOEnumParseWarning", + "USPTODataMismatchWarning", # Bulk Data API "BulkDataClient", "BulkDataResponse", diff --git a/src/pyUSPTO/clients/base.py b/src/pyUSPTO/clients/base.py index 3b22f45..9193e93 100644 --- a/src/pyUSPTO/clients/base.py +++ b/src/pyUSPTO/clients/base.py @@ -4,6 +4,7 @@ This module provides a base client class with common functionality for all USPTO API clients. """ +import re from pathlib import Path from typing import ( Any, @@ -39,7 +40,7 @@ class FromDictProtocol(Protocol): """Protocol for classes that can be created from a dictionary.""" @classmethod - def from_dict(cls, data: Dict[str, Any]) -> Any: + def from_dict(cls, data: Dict[str, Any], include_raw_data: bool = False) -> Any: """Create an object from a dictionary.""" ... @@ -177,12 +178,13 @@ def _make_request( # Return the raw response for streaming requests if stream: - # TODO: Handle Content-Disposition return response # Parse the response based on the specified class if response_class: - parsed_response: T = response_class.from_dict(response.json()) + parsed_response: T = response_class.from_dict( + response.json(), include_raw_data=self.config.include_raw_data + ) return parsed_response # Return the raw JSON for other requests @@ -270,14 +272,59 @@ def paginate_results( offset += limit + @staticmethod + def _extract_filename_from_content_disposition( + content_disposition: Optional[str], + ) -> Optional[str]: + """Extract filename from Content-Disposition header. + + Supports both RFC 2231 (filename*) and simple filename formats. + + Args: + content_disposition: The Content-Disposition header value. + + Returns: + Optional[str]: The extracted filename, or None if not found. + + Examples: + >>> _extract_filename_from_content_disposition('attachment; filename="document.pdf"') + 'document.pdf' + >>> _extract_filename_from_content_disposition("attachment; filename*=UTF-8''file%20name.pdf") + 'file name.pdf' + """ + if not content_disposition: + return None + + # Try RFC 2231 format first (filename*=UTF-8''filename) + rfc2231_match = re.search( + r"filename\*=(?:UTF-8|utf-8)?''([^;\s]+)", content_disposition + ) + if rfc2231_match: + from urllib.parse import unquote + + return unquote(rfc2231_match.group(1)) + + # Try standard filename="..." or filename=... + filename_match = re.search( + r'filename=(?:"([^"]+)"|([^;\s]+))', content_disposition + ) + if filename_match: + return filename_match.group(1) or filename_match.group(2) + + return None + def _save_response_to_file( self, response: requests.Response, file_path: str, overwrite: bool = False ) -> str: """Save a streaming response to a file on disk. + If file_path is a directory, attempts to extract filename from + Content-Disposition header and save in that directory. + Args: response: Streaming response object from requests - file_path: Local path where file should be saved + file_path: Local path where file should be saved. Can be a file path + or a directory (in which case filename from Content-Disposition is used). overwrite: Whether to overwrite existing files. Default False Returns: @@ -285,22 +332,35 @@ def _save_response_to_file( Raises: FileExistsError: If file exists and overwrite=False + ValueError: If file_path is a directory but no filename can be determined """ - # Check for existing file from pathlib import Path path = Path(file_path) + + # If path is a directory, try to extract filename from Content-Disposition + if path.is_dir(): + content_disp = response.headers.get("Content-Disposition") + filename = self._extract_filename_from_content_disposition(content_disp) + if not filename: + raise ValueError( + f"file_path is a directory ({file_path}) but Content-Disposition " + "header does not contain a filename. Please provide a full file path." + ) + path = path / filename + + # Check for existing file if path.exists() and not overwrite: raise FileExistsError( - f"File already exists: {file_path}. Set overwrite=True to replace." + f"File already exists: {path}. Set overwrite=True to replace." ) # Save to disk with streaming - with open(file=file_path, mode="wb") as f: + with open(file=str(path), mode="wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: # Filter out keep-alive chunks f.write(chunk) - return file_path + return str(path) def _download_file(self, url: str, file_path: str, overwrite: bool = False) -> str: """Download a file directly to disk. diff --git a/src/pyUSPTO/clients/patent_data.py b/src/pyUSPTO/clients/patent_data.py index 983363f..ffba998 100644 --- a/src/pyUSPTO/clients/patent_data.py +++ b/src/pyUSPTO/clients/patent_data.py @@ -5,6 +5,7 @@ It allows you to search for and retrieve patent application data. """ +import warnings from pathlib import Path from typing import Any, Dict, Iterator, List, Optional from urllib.parse import urljoin, urlparse @@ -30,6 +31,7 @@ StatusCodeCollection, StatusCodeSearchResponse, ) +from pyUSPTO.warnings import USPTODataMismatchWarning class PatentDataClient(BaseUSPTOClient[PatentDataResponse]): @@ -67,7 +69,80 @@ def __init__( api_key=api_key_to_use, base_url=effective_base_url, config=self.config ) - # TODO: def sanitize_application_no(inputNumber: str) -> str: + def sanitize_application_number(self, input_number: str) -> str: + """Sanitize and validate a USPTO application number. + + Application numbers are either: + - 8 digits (e.g., "16123456") + - Series code format: 2 digits + "/" + 6 digits (e.g., "08/123456") + + This method removes common separators (commas, spaces) while preserving + the "/" in series code format. + + Args: + input_number: Raw application number input. May include commas, + spaces, or other formatting. + + Returns: + str: Sanitized application number (either "NNNNNNNN" or "NN/NNNNNN"). + + Raises: + ValueError: If the format is invalid. + + Examples: + >>> client.sanitize_application_number("16123456") + "16123456" + >>> client.sanitize_application_number("16,123,456") + "16123456" + >>> client.sanitize_application_number("08/123456") + "08/123456" + >>> client.sanitize_application_number("08/123,456") + "08/123456" + """ + if not input_number or not input_number.strip(): + raise ValueError("Application number cannot be empty") + + # Strip whitespace and remove commas/spaces + cleaned = input_number.strip().replace(",", "").replace(" ", "") + + # Check if this is series code format (NN/NNNNNN) + if "/" in cleaned: + parts = cleaned.split("/") + if len(parts) != 2: + raise ValueError( + f"Invalid application number format: {input_number}. " + "Expected format: NNNNNNNN or NN/NNNNNN" + ) + + series, serial = parts + if not series.isdigit() or not serial.isdigit(): + raise ValueError( + f"Invalid application number format: {input_number}. " + "Series and serial must be numeric." + ) + + if len(series) != 2 or len(serial) != 6: + raise ValueError( + f"Invalid application number format: {input_number}. " + "Expected series code format: NN/NNNNNN (2 digits / 6 digits)" + ) + + return cleaned + + # Standard 8-digit format + if not cleaned.isdigit(): + raise ValueError( + f"Invalid application number format: {input_number}. " + "Must contain only digits." + ) + + if len(cleaned) != 8: + raise ValueError( + f"Invalid application number format: {input_number}. " + "Expected 8 digits." + ) + + return cleaned def _get_wrapper_from_response( self, @@ -80,15 +155,17 @@ def _get_wrapper_from_response( wrapper = response_data.patent_file_wrapper_data_bag[0] - # This should probably just raise an exception rather than print a warning. - # if ( - # application_number_for_validation - # and wrapper.application_number_text != application_number_for_validation - # ): - # print( - # f"Warning: Fetched wrapper application number '{wrapper.application_number_text}' " - # f"does not match requested '{application_number_for_validation}'." - # ) + if ( + application_number_for_validation + and wrapper.application_number_text != application_number_for_validation + ): + warnings.warn( + f"API returned application number '{wrapper.application_number_text}' " + f"but requested '{application_number_for_validation}'. " + f"This may indicate an API data inconsistency.", + USPTODataMismatchWarning, + stacklevel=2, + ) return wrapper def search_applications( diff --git a/src/pyUSPTO/clients/petition_decisions.py b/src/pyUSPTO/clients/petition_decisions.py index 14f17ba..2dd0ac1 100644 --- a/src/pyUSPTO/clients/petition_decisions.py +++ b/src/pyUSPTO/clients/petition_decisions.py @@ -6,6 +6,7 @@ decisions in publicly available patent applications and patents filed in 2001 or later. """ +import warnings from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Union @@ -19,6 +20,7 @@ PetitionDecisionDownloadResponse, PetitionDecisionResponse, ) +from pyUSPTO.warnings import USPTODataMismatchWarning class FinalPetitionDecisionsClient(BaseUSPTOClient[PetitionDecisionResponse]): @@ -81,16 +83,18 @@ def _get_decision_from_response( decision = response_data.petition_decision_data_bag[0] - # This should probably just raise an exception rather than print a warning. - # if ( - # petition_decision_record_identifier_for_validation - # and decision.petition_decision_record_identifier - # != petition_decision_record_identifier_for_validation - # ): - # print( - # f"Warning: Fetched decision identifier '{decision.petition_decision_record_identifier}' " - # f"does not match requested '{petition_decision_record_identifier_for_validation}'." - # ) + if ( + petition_decision_record_identifier_for_validation + and decision.petition_decision_record_identifier + != petition_decision_record_identifier_for_validation + ): + warnings.warn( + f"API returned decision identifier '{decision.petition_decision_record_identifier}' " + f"but requested '{petition_decision_record_identifier_for_validation}'. " + f"This may indicate an API data inconsistency.", + USPTODataMismatchWarning, + stacklevel=2, + ) return decision def search_decisions( diff --git a/src/pyUSPTO/config.py b/src/pyUSPTO/config.py index ab2bde0..32819d9 100644 --- a/src/pyUSPTO/config.py +++ b/src/pyUSPTO/config.py @@ -25,6 +25,7 @@ def __init__( patent_data_base_url: str = "https://api.uspto.gov", petition_decisions_base_url: str = "https://api.uspto.gov", http_config: Optional[HTTPConfig] = None, + include_raw_data: bool = False, ): """Initialize the USPTOConfig. @@ -34,6 +35,7 @@ def __init__( patent_data_base_url: Base URL for the Patent Data API petition_decisions_base_url: Base URL for the Final Petition Decisions 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) """ # Use environment variable only if api_key is None, not if it's an empty string self.api_key = ( @@ -46,6 +48,9 @@ def __init__( # Use provided HTTPConfig or create default self.http_config = http_config if http_config is not None else HTTPConfig() + # Control whether to include raw JSON data in response objects + self.include_raw_data = include_raw_data + @classmethod def from_env(cls) -> "USPTOConfig": """Create a USPTOConfig from environment variables. diff --git a/src/pyUSPTO/models/bulk_data.py b/src/pyUSPTO/models/bulk_data.py index 468e9da..f8c23ef 100644 --- a/src/pyUSPTO/models/bulk_data.py +++ b/src/pyUSPTO/models/bulk_data.py @@ -4,7 +4,8 @@ This module provides data models for the USPTO Open Data Portal (ODP) Bulk Data API. """ -from dataclasses import dataclass +import json +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -103,20 +104,38 @@ def from_dict(cls, data: Dict[str, Any]) -> "BulkDataProduct": @dataclass class BulkDataResponse: - """Top-level response from the bulk data API.""" + """Top-level response from the bulk data API. + + Attributes: + count: The number of bulk data products in the response. + bulk_data_product_bag: List of bulk data products. + raw_data: Optional raw JSON data from the API response (for debugging). + """ count: int bulk_data_product_bag: List[BulkDataProduct] + raw_data: Optional[str] = field(default=None, compare=False, repr=False) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "BulkDataResponse": - """Create a BulkDataResponse object from a dictionary.""" + def from_dict( + cls, data: Dict[str, Any], include_raw_data: bool = False + ) -> "BulkDataResponse": + """Create a BulkDataResponse object from a dictionary. + + Args: + data: Dictionary containing API response data. + include_raw_data: If True, store the raw JSON for debugging. + + Returns: + BulkDataResponse: An instance of BulkDataResponse. + """ return cls( count=data.get("count", 0), bulk_data_product_bag=[ BulkDataProduct.from_dict(product) for product in data.get("bulkDataProductBag", []) ], + raw_data=json.dumps(data) if include_raw_data else None, ) def to_dict(self) -> Dict[str, Any]: diff --git a/src/pyUSPTO/models/patent_data.py b/src/pyUSPTO/models/patent_data.py index f857b92..243600a 100644 --- a/src/pyUSPTO/models/patent_data.py +++ b/src/pyUSPTO/models/patent_data.py @@ -2018,18 +2018,22 @@ class PatentDataResponse: Attributes: count: The total number of patent applications found matching the query. patent_file_wrapper_data_bag: A list of `PatentFileWrapper` objects. + raw_data: Optional raw JSON data from the API response (for debugging). """ count: int patent_file_wrapper_data_bag: List[PatentFileWrapper] = field(default_factory=list) - # TODO: raw as response in json + raw_data: Optional[str] = field(default=None, compare=False, repr=False) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "PatentDataResponse": + def from_dict( + cls, data: Dict[str, Any], include_raw_data: bool = False + ) -> "PatentDataResponse": """Creates a `PatentDataResponse` instance from a dictionary. Args: data (Dict[str, Any]): Dictionary with API response data. + include_raw_data (bool): If True, store the raw JSON for debugging. Returns: PatentDataResponse: An instance of `PatentDataResponse`. @@ -2039,7 +2043,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "PatentDataResponse": for w in data.get("patentFileWrapperDataBag", []) if isinstance(w, dict) ] - return cls(count=data.get("count", 0), patent_file_wrapper_data_bag=wrappers) + return cls( + count=data.get("count", 0), + patent_file_wrapper_data_bag=wrappers, + raw_data=json.dumps(data) if include_raw_data else None, + ) def to_dict(self) -> Dict[str, Any]: """Converts the `PatentDataResponse` instance to a dictionary. diff --git a/src/pyUSPTO/models/petition_decisions.py b/src/pyUSPTO/models/petition_decisions.py index 3e00d62..1a22a16 100644 --- a/src/pyUSPTO/models/petition_decisions.py +++ b/src/pyUSPTO/models/petition_decisions.py @@ -6,6 +6,7 @@ cover petition decision records, associated documents, and download options. """ +import json from dataclasses import dataclass, field from datetime import date, datetime from enum import Enum @@ -387,18 +388,23 @@ class PetitionDecisionResponse: count: The number of petition decisions returned in this response. request_identifier: A unique identifier for the API request. petition_decision_data_bag: List of petition decision records. + raw_data: Optional raw JSON data from the API response (for debugging). """ count: Optional[int] = None request_identifier: Optional[str] = None petition_decision_data_bag: List[PetitionDecision] = field(default_factory=list) + raw_data: Optional[str] = field(default=None, compare=False, repr=False) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "PetitionDecisionResponse": + def from_dict( + cls, data: Dict[str, Any], include_raw_data: bool = False + ) -> "PetitionDecisionResponse": """Creates a PetitionDecisionResponse instance from a dictionary. Args: data: Dictionary containing API response data. + include_raw_data: If True, store the raw JSON for debugging. Returns: PetitionDecisionResponse: An instance of PetitionDecisionResponse. @@ -419,6 +425,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PetitionDecisionResponse": count=data.get("count"), request_identifier=data.get("requestIdentifier"), petition_decision_data_bag=decisions, + raw_data=json.dumps(data) if include_raw_data else None, ) def to_dict(self) -> Dict[str, Any]: diff --git a/src/pyUSPTO/warnings.py b/src/pyUSPTO/warnings.py index 92a9661..16ce924 100644 --- a/src/pyUSPTO/warnings.py +++ b/src/pyUSPTO/warnings.py @@ -66,3 +66,14 @@ class USPTOEnumParseWarning(USPTODataWarning): """ pass + + +class USPTODataMismatchWarning(USPTODataWarning): + """Warning for data validation mismatches. + + Raised when the API returns data that doesn't match the requested + identifier (e.g., requesting application 12345678 but receiving 87654321). + This indicates a potential API inconsistency or data integrity issue. + """ + + pass diff --git a/tests/clients/test_base.py b/tests/clients/test_base.py index d042e36..014c9e0 100644 --- a/tests/clients/test_base.py +++ b/tests/clients/test_base.py @@ -5,7 +5,7 @@ """ from typing import Any, Dict, cast -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch import pytest import requests @@ -81,7 +81,9 @@ class TestResponseClass: data: Dict[str, Any] @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "TestResponseClass": + def from_dict( + cls, data: Dict[str, Any], include_raw_data: bool = False + ) -> "TestResponseClass": """Create a TestResponseClass object from a dictionary.""" instance = cls() instance.data = data @@ -663,3 +665,106 @@ def test_base_client_api_key_priority(self) -> None: # Explicit api_key should take precedence assert client.api_key == "explicit_key" + + +class TestContentDispositionParsing: + """Tests for Content-Disposition header parsing.""" + + def test_extract_filename_simple(self) -> None: + """Test extracting filename from simple Content-Disposition.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + 'attachment; filename="document.pdf"' + ) + assert filename == "document.pdf" + + def test_extract_filename_without_quotes(self) -> None: + """Test extracting filename without quotes.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + "attachment; filename=document.pdf" + ) + assert filename == "document.pdf" + + def test_extract_filename_rfc2231(self) -> None: + """Test extracting filename from RFC 2231 format.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + "attachment; filename*=UTF-8''my%20document.pdf" + ) + assert filename == "my document.pdf" + + def test_extract_filename_rfc2231_lowercase(self) -> None: + """Test extracting filename from RFC 2231 format (lowercase).""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + "attachment; filename*=utf-8''test%20file.txt" + ) + assert filename == "test file.txt" + + def test_extract_filename_empty_header(self) -> None: + """Test extracting filename from empty header.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition("") + assert filename is None + + def test_extract_filename_no_filename(self) -> None: + """Test extracting filename when header has no filename.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + "attachment" + ) + assert filename is None + + def test_extract_filename_complex(self) -> None: + """Test extracting filename from complex header.""" + filename = BaseUSPTOClient._extract_filename_from_content_disposition( + 'attachment; filename="report.pdf"; size=12345' + ) + assert filename == "report.pdf" + + +class TestSaveResponseToFile: + """Tests for _save_response_to_file method.""" + + @patch("builtins.open", new_callable=mock_open) + def test_save_to_directory_with_content_disposition( + self, mock_file_open: MagicMock, tmp_path: Any + ) -> None: + """Test saving to directory extracts filename from Content-Disposition.""" + from pathlib import Path + + # Create a test client + client: BaseUSPTOClient[Any] = BaseUSPTOClient( + api_key="test", base_url="https://test.com" + ) + + # Mock response with Content-Disposition header + mock_response = MagicMock() + mock_response.headers = {"Content-Disposition": 'attachment; filename="test_doc.pdf"'} + mock_response.iter_content.return_value = [b"data1", b"data2"] + + # Save to directory (using tmp_path from pytest fixture) + result = client._save_response_to_file(mock_response, str(tmp_path)) + + # Verify the file was saved with extracted filename + expected_path = tmp_path / "test_doc.pdf" + mock_file_open.assert_called_once_with(file=str(expected_path), mode="wb") + assert result == str(expected_path) + + @patch("builtins.open", new_callable=mock_open) + def test_save_to_directory_without_content_disposition( + self, mock_file_open: MagicMock, tmp_path: Any + ) -> None: + """Test saving to directory without Content-Disposition raises ValueError.""" + from pathlib import Path + + client: BaseUSPTOClient[Any] = BaseUSPTOClient( + api_key="test", base_url="https://test.com" + ) + + # Mock response without Content-Disposition header + mock_response = MagicMock() + mock_response.headers = {} + mock_response.iter_content.return_value = [b"data"] + + # Should raise ValueError when trying to save to directory without filename + with pytest.raises( + ValueError, + match="file_path is a directory .* but Content-Disposition header does not contain a filename", + ): + client._save_response_to_file(mock_response, str(tmp_path)) diff --git a/tests/clients/test_patent_data_clients.py b/tests/clients/test_patent_data_clients.py index 4187b90..7470a2f 100644 --- a/tests/clients/test_patent_data_clients.py +++ b/tests/clients/test_patent_data_clients.py @@ -23,6 +23,7 @@ from pyUSPTO.clients.patent_data import PatentDataClient from pyUSPTO.config import USPTOConfig from pyUSPTO.exceptions import USPTOApiBadRequestError, USPTOApiError +from pyUSPTO.warnings import USPTODataMismatchWarning from pyUSPTO.models.patent_data import ( ApplicationContinuityData, ApplicationMetaData, @@ -1092,13 +1093,15 @@ def test_download_file_success( method="GET", endpoint="", stream=True, custom_url=url ) - # Verify file operations - mock_file_open.assert_called_once_with(file=file_path, mode="wb") + # Verify file operations - use str(Path()) to normalize path for platform + from pathlib import Path + expected_path = str(Path(file_path)) + mock_file_open.assert_called_once_with(file=expected_path, mode="wb") mock_file_open().write.assert_has_calls( [mock.call(b"chunk1"), mock.call(b"chunk2")] ) - assert result == file_path + assert result == expected_path @patch.object(BaseUSPTOClient, "_make_request") def test_download_file_wrong_response_type( @@ -1224,14 +1227,22 @@ def test_get_ifw_by_pct_app_number( client_with_mocked_request: tuple[PatentDataClient, MagicMock], mock_patent_file_wrapper: PatentFileWrapper, ) -> None: - """Test get_IFW with PCT_app_number calls get_application_by_number.""" + """Test get_IFW with PCT_app_number calls get_application_by_number. + + Note: This will trigger a data mismatch warning because the mock_patent_file_wrapper + has application_number_text='12345678' but we're requesting a PCT number. + This is expected test behavior for validating the warning system. + """ client, mock_make_request = client_with_mocked_request mock_make_request.return_value = PatentDataResponse( count=1, patent_file_wrapper_data_bag=[mock_patent_file_wrapper] ) pct_app = "PCT/US2024/012345" - result = client.get_IFW_metadata(PCT_app_number=pct_app) + + # The mismatch between PCT number and regular app number triggers warning + with pytest.warns(USPTODataMismatchWarning): + result = client.get_IFW_metadata(PCT_app_number=pct_app) # Should call get_application_by_number mock_make_request.assert_called_once_with( @@ -2006,12 +2017,11 @@ def test_get_application_by_number_app_num_mismatch_in_bag( client_with_mocked_request: tuple[PatentDataClient, MagicMock], mock_patent_file_wrapper: PatentFileWrapper, ) -> None: - """Test that application number mismatch is handled. + """Test that application number mismatch raises a warning. - TODO: The validation logic is currently commented out in the source code - (see patent_data.py lines 81-90). This test verifies current behavior - where mismatched application numbers are NOT validated. When validation - is implemented, this test should be updated to expect an exception. + When the API returns a different application number than requested, + a USPTODataMismatchWarning should be issued to alert the user of + the data inconsistency. """ client, mock_make_request = client_with_mocked_request requested_app_num = "DIFFERENT_APP_NUM_999" @@ -2020,7 +2030,10 @@ def test_get_application_by_number_app_num_mismatch_in_bag( ) mock_make_request.return_value = response_with_original_wrapper - with patch("builtins.print") as mock_print: + with pytest.warns( + USPTODataMismatchWarning, + match="API returned application number '12345678' but requested 'DIFFERENT_APP_NUM_999'", + ): result = client.get_application_by_number( application_number=requested_app_num ) @@ -2028,8 +2041,6 @@ def test_get_application_by_number_app_num_mismatch_in_bag( assert result is mock_patent_file_wrapper assert result is not None assert result.application_number_text == "12345678" - # Currently no validation is performed (validation code is commented out) - mock_print.assert_not_called() def test_get_application_by_number_unexpected_response_type( self, client_with_mocked_request: tuple[PatentDataClient, MagicMock] @@ -2061,6 +2072,155 @@ def test_search_applications_post_assertion_error( client.search_applications(post_body={"q": "test"}) +class TestApplicationNumberSanitization: + """Tests for application number sanitization and validation.""" + + def test_sanitize_standard_format( + self, patent_data_client: PatentDataClient + ) -> None: + """Test sanitization of standard 8-digit format.""" + assert patent_data_client.sanitize_application_number("16123456") == "16123456" + + def test_sanitize_with_commas(self, patent_data_client: PatentDataClient) -> None: + """Test removal of commas.""" + assert ( + patent_data_client.sanitize_application_number("16,123,456") == "16123456" + ) + + def test_sanitize_with_spaces(self, patent_data_client: PatentDataClient) -> None: + """Test removal of spaces.""" + assert ( + patent_data_client.sanitize_application_number(" 16 123 456 ") == "16123456" + ) + + def test_sanitize_series_code_format( + self, patent_data_client: PatentDataClient + ) -> None: + """Test series code format (NN/NNNNNN).""" + assert ( + patent_data_client.sanitize_application_number("08/123456") == "08/123456" + ) + + def test_sanitize_series_code_with_separators( + self, patent_data_client: PatentDataClient + ) -> None: + """Test series code format with commas and spaces.""" + assert ( + patent_data_client.sanitize_application_number("08/123,456") == "08/123456" + ) + assert ( + patent_data_client.sanitize_application_number(" 08 / 123 456 ") + == "08/123456" + ) + + def test_sanitize_empty_string_raises( + self, patent_data_client: PatentDataClient + ) -> None: + """Test empty string raises ValueError.""" + with pytest.raises(ValueError, match="Application number cannot be empty"): + patent_data_client.sanitize_application_number("") + + def test_sanitize_whitespace_only_raises( + self, patent_data_client: PatentDataClient + ) -> None: + """Test whitespace-only string raises ValueError.""" + with pytest.raises(ValueError, match="Application number cannot be empty"): + patent_data_client.sanitize_application_number(" ") + + def test_sanitize_invalid_characters_raises( + self, patent_data_client: PatentDataClient + ) -> None: + """Test invalid characters raise ValueError.""" + with pytest.raises(ValueError, match="Invalid application number format"): + patent_data_client.sanitize_application_number("16ABC456") + + def test_sanitize_wrong_length_raises( + self, patent_data_client: PatentDataClient + ) -> None: + """Test wrong length raises ValueError.""" + with pytest.raises(ValueError, match="Expected 8 digits"): + patent_data_client.sanitize_application_number("1234567") # 7 digits + with pytest.raises(ValueError, match="Expected 8 digits"): + patent_data_client.sanitize_application_number("123456789") # 9 digits + + def test_sanitize_invalid_series_code_format_raises( + self, patent_data_client: PatentDataClient + ) -> None: + """Test invalid series code format raises ValueError.""" + # Wrong series length + with pytest.raises( + ValueError, match="Expected series code format: NN/NNNNNN" + ): + patent_data_client.sanitize_application_number("8/123456") # 1 digit series + + # Wrong serial length + with pytest.raises( + ValueError, match="Expected series code format: NN/NNNNNN" + ): + patent_data_client.sanitize_application_number("08/12345") # 5 digit serial + + # Non-numeric series + with pytest.raises(ValueError, match="Series and serial must be numeric"): + patent_data_client.sanitize_application_number("AB/123456") + + # Non-numeric serial + with pytest.raises(ValueError, match="Series and serial must be numeric"): + patent_data_client.sanitize_application_number("08/ABC456") + + # Multiple slashes + with pytest.raises( + ValueError, match="Expected format: NNNNNNNN or NN/NNNNNN" + ): + patent_data_client.sanitize_application_number("08/123/456") + + +class TestRawDataFeature: + """Tests for the include_raw_data feature.""" + + def test_raw_data_disabled_by_default( + self, client_with_mocked_request: tuple[PatentDataClient, MagicMock] + ) -> None: + """Test that raw_data is None by default.""" + client, mock_make_request = client_with_mocked_request + mock_response = PatentDataResponse(count=1, patent_file_wrapper_data_bag=[]) + mock_make_request.return_value = mock_response + + result = client.search_applications(query="test") + + assert result.raw_data is None + + def test_raw_data_enabled_via_config( + self, mock_patent_file_wrapper: PatentFileWrapper + ) -> None: + """Test that raw_data is populated when config.include_raw_data=True.""" + config = USPTOConfig(api_key="test_key", include_raw_data=True) + client = PatentDataClient(config=config) + + # Create a response with raw_data enabled + test_data = { + "count": 1, + "patentFileWrapperDataBag": [{"applicationNumberText": "12345678"}], + } + response = PatentDataResponse.from_dict(test_data, include_raw_data=True) + + assert response.raw_data is not None + assert "patentFileWrapperDataBag" in response.raw_data + assert response.count == 1 + + def test_raw_data_can_be_parsed_back(self) -> None: + """Test that raw_data contains valid JSON that can be parsed.""" + test_data = {"count": 42, "patentFileWrapperDataBag": []} + response = PatentDataResponse.from_dict(test_data, include_raw_data=True) + + assert response.raw_data is not None + # Parse it back + import json + + parsed = json.loads(response.raw_data) + assert parsed["count"] == 42 + assert parsed["patentFileWrapperDataBag"] == [] + + class TestInternalHelpersEdgeCases: """Tests for edge cases in internal helper methods like _get_wrapper_from_response.""" diff --git a/tests/clients/test_petition_decision_clients.py b/tests/clients/test_petition_decision_clients.py index ecaf3cd..9773251 100644 --- a/tests/clients/test_petition_decision_clients.py +++ b/tests/clients/test_petition_decision_clients.py @@ -19,6 +19,7 @@ PetitionDecisionDownloadResponse, PetitionDecisionResponse, ) +from pyUSPTO.warnings import USPTODataMismatchWarning # --- Fixtures --- @@ -897,21 +898,19 @@ def test_get_decision_from_response_id_mismatch( self, petition_client: FinalPetitionDecisionsClient, mock_petition_response_with_data: PetitionDecisionResponse, - capsys, ) -> None: - """Test _get_decision_from_response with mismatched ID. + """Test _get_decision_from_response with mismatched ID raises a warning. - TODO: The validation logic is currently commented out in the source code - (see petition_decisions.py lines 82-92). This test verifies current behavior - where mismatched IDs are NOT validated. When validation is implemented, - this test should be updated to expect an exception. + When the API returns a different decision identifier than requested, + a USPTODataMismatchWarning should be issued to alert the user of + the data inconsistency. """ - result = petition_client._get_decision_from_response( - mock_petition_response_with_data, - petition_decision_record_identifier_for_validation="different-id-12345", - ) + with pytest.warns( + USPTODataMismatchWarning, + match="API returned decision identifier .* but requested 'different-id-12345'", + ): + result = petition_client._get_decision_from_response( + mock_petition_response_with_data, + petition_decision_record_identifier_for_validation="different-id-12345", + ) assert result is not None - - # Capture stdout to verify no warning is printed (validation is commented out) - captured = capsys.readouterr() - assert "Warning" not in captured.out