Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,6 @@ pytest-cov==7.0.0
# via pyuspto
pytest-mock==3.15.1
# via pyuspto
pyuspto @ file:///C:/Users/andrewp/Documents/GitHub/pyUSPTO
# via
# pyUSPTO (pyproject.toml)
# pyuspto
pyyaml==6.0.3
# via myst-parser
requests==2.32.5
Expand Down
208 changes: 144 additions & 64 deletions src/pyUSPTO/clients/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
runtime_checkable,
)

try:
from typing import Self
except ImportError:
from typing_extensions import Self
import requests

from pyUSPTO.config import USPTOConfig
Expand All @@ -31,14 +35,17 @@ class FromDictProtocol(Protocol):
"""Protocol for classes that can be created from a dictionary."""

@classmethod
def from_dict(cls, data: dict[str, Any], include_raw_data: bool = False) -> Any:
def from_dict(cls, data: dict[str, Any], include_raw_data: bool = False) -> Self:
"""Create an object from a dictionary."""
...


# Type variable for response classes
T = TypeVar("T", bound=FromDictProtocol)

# Type variable for response classes
M = TypeVar("M", bound=FromDictProtocol)


class BaseUSPTOClient(Generic[T]):
"""Base client class for USPTO API clients."""
Expand Down Expand Up @@ -166,49 +173,56 @@ def _parse_json_response(
),
) from json_err

def _make_request(
def _build_url(
self,
method: str,
endpoint: str,
custom_url: str | None = None,
custom_base_url: str | None = None,
) -> str:
"""Build the request URL from endpoint or custom URL.

Args:
endpoint: API endpoint path (without base URL)
custom_url: Optional full custom URL (overrides endpoint and base URL)
custom_base_url: Optional custom base URL instead of self.base_url

Returns:
The resolved URL string.
"""
if custom_url:
return custom_url
base = custom_base_url if custom_base_url else self.base_url
return f"{base}/{endpoint.lstrip('/')}"

def _execute_request(
self,
method: str,
url: str,
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
stream: bool = False,
response_class: type[T] | None = None,
custom_url: str | None = None,
custom_base_url: str | None = None,
) -> dict[str, Any] | T | requests.Response:
"""Make an HTTP request to the USPTO API.
) -> requests.Response:
"""Execute an HTTP request and return the raw Response.

Note: Only GET and POST methods are supported. Other HTTP methods will
raise a ValueError.
Handles URL dispatch, error translation, and timeout/connection errors.
Callers are responsible for interpreting the response body.

Args:
method: HTTP method (GET or POST only)
endpoint: API endpoint path (without base URL)
url: Fully resolved request URL
params: Optional query parameters
json_data: Optional JSON body for POST requests
stream: Whether to stream the response
response_class: Class to use for parsing the response
custom_url: Optional full custom URL to use (overrides endpoint and base URL)
custom_base_url: Optional custom base URL to use instead of self.base_url

Returns:
Response data in the appropriate format:
- If stream=True: requests.Response object
- If response_class is provided: Instance of response_class
- Otherwise: Dict[str, Any] containing the JSON response
The raw requests.Response after raise_for_status().

Raises:
ValueError: If an unsupported HTTP method is provided
USPTOApiError: On HTTP errors from the API
USPTOTimeout: On request timeout
USPTOConnectionError: On connection failure
"""
url: str = ""
if custom_url:
url = custom_url
else:
base = custom_base_url if custom_base_url else self.base_url
url = f"{base}/{endpoint.lstrip('/')}"

# Get timeout from HTTP config
timeout = self.http_config.get_timeout_tuple()

try:
Expand All @@ -228,26 +242,10 @@ def _make_request(
raise ValueError(f"Unsupported HTTP method: {method}")

response.raise_for_status()

# Return the raw response for streaming requests
if stream:
return response

# Parse JSON response with error handling
json_data = self._parse_json_response(response, url)

# Parse the response based on the specified class
if response_class:
parsed_response: T = response_class.from_dict(
json_data, include_raw_data=self.config.include_raw_data
)
return parsed_response

# Return the raw JSON for other requests
return json_data
return response

except requests.exceptions.HTTPError as http_err:
client_operation_message = f"API request to '{url}' failed with HTTPError" # 'url' is from _make_request scope
client_operation_message = f"API request to '{url}' failed with HTTPError"

# Include request body for POST debugging
if method.upper() == "POST" and json_data:
Expand All @@ -257,7 +255,6 @@ def _make_request(
f"\nRequest body sent:\n{json.dumps(json_data, indent=2)}"
)

# Create APIErrorArgs directly from the HTTPError
current_error_args = APIErrorArgs.from_http_error(
http_error=http_err, client_operation_message=client_operation_message
)
Expand All @@ -266,39 +263,127 @@ def _make_request(
raise api_exception_to_raise from http_err

except requests.exceptions.Timeout as timeout_err:
# Specific handling for timeout errors
raise USPTOTimeout(
message=f"Request to '{url}' timed out",
api_short_error="Timeout",
error_details=str(timeout_err),
) from timeout_err

except requests.exceptions.ConnectionError as conn_err:
# Specific handling for connection errors (DNS, refused connection, etc.)
raise USPTOConnectionError(
message=f"Failed to connect to '{url}'",
api_short_error="Connection Error",
error_details=str(conn_err),
) from conn_err

except (
requests.exceptions.RequestException
) as req_err: # Catches other non-HTTP errors from requests
client_operation_message = (
f"API request to '{url}' failed" # 'url' is from _make_request scope
)
except requests.exceptions.RequestException as req_err:
client_operation_message = f"API request to '{url}' failed"

# Create APIErrorArgs from the generic RequestException
current_error_args = APIErrorArgs.from_request_exception(
request_exception=req_err,
client_operation_message=client_operation_message, # or pass None if you prefer default message
client_operation_message=client_operation_message,
)

api_exception_to_raise = get_api_exception(
current_error_args
) # Will default to USPTOApiError
api_exception_to_raise = get_api_exception(current_error_args)
raise api_exception_to_raise from req_err

def _stream_request(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
custom_url: str | None = None,
custom_base_url: str | None = None,
) -> requests.Response:
"""Make a streaming HTTP request and return the raw Response.

Args:
method: HTTP method (GET or POST only)
endpoint: API endpoint path (without base URL)
params: Optional query parameters
json_data: Optional JSON body for POST requests
custom_url: Optional full custom URL (overrides endpoint and base URL)
custom_base_url: Optional custom base URL instead of self.base_url

Returns:
Streaming requests.Response object.
"""
url = self._build_url(
endpoint, custom_url=custom_url, custom_base_url=custom_base_url
)
return self._execute_request(
method=method, url=url, params=params, json_data=json_data, stream=True
)

def _get_model(
self,
method: str,
endpoint: str,
response_class: type[M],
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
custom_url: str | None = None,
custom_base_url: str | None = None,
) -> M:
"""Make an HTTP request and parse the response into a model.

Args:
method: HTTP method (GET or POST only)
endpoint: API endpoint path (without base URL)
response_class: Class to use for parsing the response
params: Optional query parameters
json_data: Optional JSON body for POST requests
custom_url: Optional full custom URL (overrides endpoint and base URL)
custom_base_url: Optional custom base URL instead of self.base_url

Returns:
Instance of response_class parsed from the JSON response.
"""
url = self._build_url(
endpoint, custom_url=custom_url, custom_base_url=custom_base_url
)
response = self._execute_request(
method=method, url=url, params=params, json_data=json_data
)
data = self._parse_json_response(response, url)

ret = response_class.from_dict(
data, include_raw_data=self.config.include_raw_data
)
assert isinstance(ret, response_class)
return ret

def _get_json(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
custom_url: str | None = None,
custom_base_url: str | None = None,
) -> dict[str, Any]:
"""Make an HTTP request and return the parsed JSON response.

Args:
method: HTTP method (GET or POST only)
endpoint: API endpoint path (without base URL)
params: Optional query parameters
json_data: Optional JSON body for POST requests
custom_url: Optional full custom URL (overrides endpoint and base URL)
custom_base_url: Optional custom base URL instead of self.base_url

Returns:
Dict containing the JSON response.
"""
url = self._build_url(
endpoint, custom_url=custom_url, custom_base_url=custom_base_url
)
response = self._execute_request(
method=method, url=url, params=params, json_data=json_data
)
return self._parse_json_response(response, url)

def paginate_results(
self,
method_name: str,
Expand Down Expand Up @@ -785,19 +870,14 @@ def _download_file(
Path to downloaded file

Raises:
TypeError: If response is not a valid Response object
FileExistsError: If file exists and overwrite is False
"""
response = self._make_request(
response = self._stream_request(
method="GET",
endpoint="",
stream=True,
custom_url=url,
)

if not isinstance(response, requests.Response):
raise TypeError(f"Expected Response, got {type(response)}")

return self._save_response_to_file(
response=response,
destination=destination,
Expand Down
13 changes: 4 additions & 9 deletions src/pyUSPTO/clients/bulk_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,12 @@ def get_product_by_id(
params["latest"] = str(latest).lower()

# Use response_class for clean parsing
response = self._make_request(
response = self._get_model(
method="GET",
endpoint=endpoint,
params=params if params else None,
response_class=BulkDataResponse,
params=params if params else None,
)
assert isinstance(response, BulkDataResponse)

# Extract the product from response
if response.bulk_data_product_bag:
Expand Down Expand Up @@ -251,13 +250,9 @@ def search_products(
if fields is not None:
params["fields"] = ",".join(fields)

result = self._make_request(
return self._get_model(
method="GET",
endpoint=self.ENDPOINTS["products_search"],
params=params,
response_class=BulkDataResponse,
params=params,
)

# Since we specified response_class=BulkDataResponse, the result should be a BulkDataResponse
assert isinstance(result, BulkDataResponse)
return result
Loading
Loading