Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
51eb51b
docs: add 0.6.1 to changelog
dpieski Nov 5, 2026
70862f0
fix: strip the slash from series-code application numbers
dpieski Nov 5, 2026
3029119
fix: sanitize application_number_q in search
dpieski Nov 5, 2026
d6cd617
fix: note that a missing-token 403 can mean a bad path
dpieski Nov 5, 2026
e02bcd5
fix: percent-encode IDs and file names in URL paths
dpieski Nov 5, 2026
34f2d62
docs: correct the 0.6.1 workflow line
dpieski Nov 5, 2026
629dc52
docs: date the 0.6.0 changelog entry
dpieski Nov 6, 2026
9aeaaf1
docs: note the bad-path case in the error example
dpieski Nov 6, 2026
23bcbfc
test: search a PCT application number live
dpieski Nov 6, 2026
19ca521
docs: describe application-number cleanup in search and lookup
dpieski Nov 6, 2026
739904b
fix: apply application_number_q to every term of an expression
dpieski Nov 6, 2026
5da5716
fix: accept an integer application_number_q
dpieski Nov 6, 2026
28ff2f5
fix: clean application numbers in the other clients' search filters
dpieski Nov 6, 2026
896c367
refactor: add shared query builders
dpieski Nov 6, 2026
57560aa
docs: drop the integer changelog line
dpieski Nov 6, 2026
33776de
fix: escape and wrap patent data search values
dpieski Nov 6, 2026
77800c5
fix: escape and wrap petition search values
dpieski Nov 6, 2026
69002a5
fix: quote PTAB appeals name filters
dpieski Nov 6, 2026
9159f99
refactor: build PTAB interference queries with the shared builders
dpieski Nov 6, 2026
fec4db5
refactor: build PTAB trial queries with the shared builders
dpieski Nov 6, 2026
077c617
refactor: build OA and citation queries with the shared builders
dpieski Nov 6, 2026
aafc71e
refactor: remove the old query helpers
dpieski Nov 6, 2026
078047b
fix: apply the query builder review fixes
dpieski Nov 6, 2026
3cd4ffd
test: let PTAB appeals live tests raise API errors
dpieski Nov 6, 2026
7b9b6b3
docs: note that a quoted wildcard does not expand
dpieski Nov 10, 2026
33e34c7
fix: raise on an empty list filter
dpieski Nov 10, 2026
68ec750
fix: treat OA and citation categories as code filters
dpieski Nov 10, 2026
8fa3071
fix: accept non-string IDs in URL paths
dpieski Nov 10, 2026
87542bd
fix: request the real file name in bulk downloads
dpieski Nov 10, 2026
47ea7a3
fix: escape quotes in quoted contains filters
dpieski Nov 10, 2026
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
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,39 @@ All notable changes to the pyUSPTO package will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0]
## [Unreleased]

### Changed

- `sanitize_application_number` now returns series-code input as 8 digits (`18/511,739` → `18511739`). Previously it kept the slash, so `get_application_by_number` and the other per-application methods requested `.../applications/18/511739`, which the API rejects with HTTP 403. This fixes every method that takes an application number, including `get_IFW_metadata(application_number=...)` and `get_IFW(application_number=...)`.
- A 403 response with the body `{"message":"Missing Authentication Token"}` now adds to its exception message that USPTO also returns this body when the requested URL path does not exist. It is still raised as `USPTOApiAuthError`.
- `error_handling_example.py` notes that a "Missing Authentication Token" error can also mean a bad URL path.
- Every client builds its convenience filters with one shared module, `pyUSPTO.clients.query`. `PTABTrialsClient` and `PTABInterferencesClient` no longer quote a single-word text value. Live, `trialStatusCategory:Instituted` and `trialStatusCategory:"Instituted"` both return 220 proceedings. `PTABInterferencesClient` also wraps each party-name filter's `OR` group in parentheses, which returned the same records as before in live checks.

### Fixed

- `application_number_q` (used by `search_applications` and `get_search_results`) now goes through `sanitize_application_number`, so `14/412875`, `14/412,875`, and `14412875` all match, and `PCT/US2025/047756` is sent as `PCTUS2025047756`. Previously the value was sent verbatim and `14/412875` matched nothing. Values containing `*`, and values the sanitizer rejects (such as range or `OR` expressions), are not sanitized.
- `application_number_q` values that are not a single application number (wildcards, `OR` and range expressions) are now sent inside parentheses, so the field applies to every term. Previously it applied only to the first term: `14412875 OR 10000000` searched `10000000` as free text and returned 3 applications instead of 1.
- The application-number filters on the other clients get the same cleanup as `application_number_q`, including the parentheses above: `PetitionDecisionsClient` (`application_number_q`), `PTABAppealsClient` (`application_number_text_q`), `PTABInterferencesClient` (`senior_party_application_number_q`, `junior_party_application_number_q`), `PTABTrialsClient.search_decisions` (`application_number_q`), and `patent_application_number_q` on `OAActionsClient`, `OACitationsClient`, `OARejectionsClient`, and `EnrichedCitationsClient`. Previously `17/765301` matched nothing in any of them, and `17/765,301` matched nothing in the office action and enriched citation APIs.
- Text filters (names, titles, categories, classifications) are quoted when the value contains whitespace or one of `+ - & | ! ( ) { } [ ] ^ " ~ : \ /`, and double quotes and backslashes inside the quotes are escaped. A single plain word, including one with a `*` or `?` wildcard, is sent unquoted. A wildcard value that also contains one of those characters (`Garcia-Lopez*`) is quoted, so its wildcard is searched as plain text. Live, quoted `"Garcia-Lopez*"` returns the same 119 applications as `"Garcia-Lopez"`, and the unquoted form, sent before this change, returned HTTP 404. Before, the filters that quoted at all did so only for a space (or, for `classification_q`, a slash), so `assignee_name_q="Smith-Kline"` was sent unquoted, and an inner quote broke the phrase: `inventor_name_q='Smith "John'` matched nothing and now matches. Applies to the convenience filters of every search client.
- Code filters (such as `patent_number_q`) send a value containing spaces inside parentheses, so the field applies to every term. `patent_number_q="10000000 OR 10000001"` returned 3 applications and now returns 2. Applies to the convenience filters of every search client.
- `PTABAppealsClient` `appellant_name_q`, `requestor_name_q`, and `decision_type_category_q` were never quoted and are now quoted under the rule above. A multi-word value searched every word after the first as free text: `appellant_name_q="Block, Inc."` returned 4303 decisions from 2025 instead of 1.
- The office action and enriched citation category filters are code filters under the rule above: `OAActionsClient` `access_level_category_q` and `application_type_category_q`, `action_type_category_q` on `OACitationsClient` and `OARejectionsClient`, and `EnrichedCitationsClient` `office_action_category_q`. Their values are single-word codes (`PUBLIC`, `REGULAR`, `rejected`, `CTNF`), so `office_action_category_q="CTNF OR CTFR"` is sent as `officeActionCategory:(CTNF OR CTFR)`, and the `OR` now applies to the field.
- `customer_number_q=[]` and `status_code_q=[]` raise `ValueError` instead of sending the malformed clause `field:()`. Dropping the filter instead would have run the search unfiltered.
- `OAActionsClient` `group_art_unit_number_q=""` adds no filter. It sent `groupArtUnitNumber:`.
- `PetitionDecisionsClient.get_decision_by_id`, `BulkDataClient.get_product_by_id`, and `BulkDataClient.download_file` (when the file has no download URI) now percent-encode the ID or file name in the URL path. Previously a `?` or `/` in the value changed the request; `get_decision_by_id("abc?x")` requested decision `abc`. A non-string ID, such as a `uuid.UUID`, still works as it did before.
- `PTABTrialsClient.search_decisions(document_type_description_q=...)` escapes double quotes and backslashes in the value. A `"` in the value closed the quoted search early and broke the query.
- `BulkDataClient.download_file(file_name=...)` uses `file_name` only as the local save name. When the file had no download URI, it also requested `file_name` from the server instead of the real file, so `download_file(file_data, file_name="custom.zip")` asked for `.../custom.zip`.

## [0.6.1] - 2026-10-27

### Changed

- `requirements.txt` pins `certifi` 2026.10.23 (was 2026.7.22).
- The lint job (ruff, mypy) and the docs workflow run on Python 3.15 (was 3.14).
- Development dependency updates: certifi 2026.10.23, coverage 7.17.0, pluggy 1.7.0, pytest 9.2.0, pytest-mock 3.17.0, ruff 0.17.3, sphinx 9.2.0, sphinx-autodoc-typehints 3.17.4, sphinxcontrib-jsmath 2.0.0.

## [0.6.0] - 2026-10-27

### Added

Expand Down
3 changes: 2 additions & 1 deletion examples/error_handling_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
except USPTOApiAuthError as e:
print(f"Authentication failed: {e}")
print("Check your API key and try again.")
print("A 'Missing Authentication Token' message can also mean a bad URL path.")

print("-" * 40)
print("Example 2: Not found errors")
Expand Down Expand Up @@ -95,7 +96,7 @@
except USPTOApiRateLimitError:
print("Rate limited - wait and retry")
except USPTOApiAuthError:
print("Authentication failed - check API key")
print("Authentication failed - check API key or URL path")
except USPTOTimeout:
print("Request timed out - try again")
except USPTOConnectionError:
Expand Down
18 changes: 18 additions & 0 deletions src/pyUSPTO/clients/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TypeVar,
runtime_checkable,
)
from urllib.parse import quote

import requests

Expand Down Expand Up @@ -196,6 +197,23 @@ def _build_url(
base = custom_base_url if custom_base_url else self.base_url
return f"{base}/{endpoint.lstrip('/')}"

@staticmethod
def _quote_path_value(value: object) -> str:
"""Percent-encode a caller value for use as one URL path segment.

Every reserved character is encoded, including ``/``, ``?``, and
``#``, so the value cannot add path segments, a query string, or a
fragment to the request. A non-string value, such as a ``uuid.UUID``,
is formatted with ``str()`` first.

Args:
value: The raw value, such as a record ID or file name.

Returns:
The encoded value.
"""
return quote(str(value), safe="")

def _execute_request(
self,
method: str,
Expand Down
12 changes: 8 additions & 4 deletions src/pyUSPTO/clients/bulk_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ def get_product_by_id(
... latest=True
... )
"""
endpoint = self.ENDPOINTS["product_by_id"].format(product_id=product_id)
endpoint = self.ENDPOINTS["product_by_id"].format(
product_id=self._quote_path_value(product_id)
)

params = {}
if file_data_from_date:
Expand Down Expand Up @@ -147,7 +149,9 @@ def download_file(
Args:
file_data: FileData object containing download info and product_identifier.
destination: Directory to save/extract to. Defaults to current directory.
file_name: Override filename. Defaults to file_data.file_name.
file_name: Name to save the file under locally. Defaults to
file_data.file_name. The download always requests
file_data.file_name from the server.
overwrite: Whether to overwrite existing files. Defaults to False.
extract: Whether to auto-extract archives. Defaults to False.

Expand All @@ -171,8 +175,8 @@ def download_file(
download_url = file_data.file_download_uri
else:
endpoint = self.ENDPOINTS["download_file"].format(
productIdentifier=file_data.product_identifier,
fileName=default_file_name,
productIdentifier=self._quote_path_value(file_data.product_identifier),
fileName=self._quote_path_value(file_data.file_name),
)
download_url = f"{self.base_url}/{endpoint}"

Expand Down
67 changes: 32 additions & 35 deletions src/pyUSPTO/clients/enriched_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
from typing import Any

from pyUSPTO.clients.base import BaseUSPTOClient
from pyUSPTO.clients.query import (
all_of,
application_number,
boolean,
date_range,
phrase,
term,
)
from pyUSPTO.config import USPTOConfig
from pyUSPTO.models.enriched_citations import (
EnrichedCitation,
Expand Down Expand Up @@ -84,6 +92,12 @@ def search_citations(
You can provide either a direct post_body, a query string, or use convenience
parameters that will be automatically combined into a query.

``cited_document_identifier_q`` is quoted when it contains spaces or
special characters, with inner quotes escaped. The code filters
(office action and citation category codes, tech centers, art units)
wrap a value that contains spaces, such as ``"CTNF OR CTFR"``, in
parentheses, so the field applies to every term.

Args:
query: Direct query string in USPTO search syntax.
sort: Sort order for results.
Expand All @@ -92,6 +106,8 @@ def search_citations(
post_body: Optional POST body for complex queries. When provided,
all other parameters are ignored.
patent_application_number_q: Filter by patent application number.
Any common form matches (``17/765,301``, ``17765301``). Write
each number in an ``OR`` or range expression as 8 digits.
cited_document_identifier_q: Filter by cited document identifier.
office_action_category_q: Filter by office action category (e.g., "CTNF").
citation_category_code_q: Filter by citation category code (e.g., "X", "Y").
Expand Down Expand Up @@ -141,41 +157,22 @@ def search_citations(
# Build query from convenience parameters
final_q = query
if final_q is None:
q_parts = []
if patent_application_number_q:
q_parts.append(f"patentApplicationNumber:{patent_application_number_q}")
if cited_document_identifier_q:
v = (
f'"{cited_document_identifier_q}"'
if " " in cited_document_identifier_q
else cited_document_identifier_q
)
q_parts.append(f"citedDocumentIdentifier:{v}")
if office_action_category_q:
q_parts.append(f"officeActionCategory:{office_action_category_q}")
if citation_category_code_q:
q_parts.append(f"citationCategoryCode:{citation_category_code_q}")
if tech_center_q:
q_parts.append(f"techCenter:{tech_center_q}")
if group_art_unit_number_q:
q_parts.append(f"groupArtUnitNumber:{group_art_unit_number_q}")
if examiner_cited_q is not None:
q_parts.append(
f"examinerCitedReferenceIndicator:{str(examiner_cited_q).lower()}"
)

# Handle office action date range
if office_action_date_from_q and office_action_date_to_q:
q_parts.append(
f"officeActionDate:[{office_action_date_from_q} TO {office_action_date_to_q}]"
)
elif office_action_date_from_q:
q_parts.append(f"officeActionDate:>={office_action_date_from_q}")
elif office_action_date_to_q:
q_parts.append(f"officeActionDate:<={office_action_date_to_q}")

if q_parts:
final_q = " AND ".join(q_parts)
final_q = all_of(
application_number(
"patentApplicationNumber", patent_application_number_q
),
phrase("citedDocumentIdentifier", cited_document_identifier_q),
term("officeActionCategory", office_action_category_q),
term("citationCategoryCode", citation_category_code_q),
term("techCenter", tech_center_q),
term("groupArtUnitNumber", group_art_unit_number_q),
boolean("examinerCitedReferenceIndicator", examiner_cited_q),
date_range(
"officeActionDate",
office_action_date_from_q,
office_action_date_to_q,
),
)

if final_q is not None:
body["criteria"] = final_q
Expand Down
53 changes: 26 additions & 27 deletions src/pyUSPTO/clients/oa_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
from typing import Any

from pyUSPTO.clients.base import BaseUSPTOClient
from pyUSPTO.clients.query import (
all_of,
application_number,
date_range,
term,
)
from pyUSPTO.config import USPTOConfig
from pyUSPTO.models.oa_actions import (
OAActionsFieldsResponse,
Expand Down Expand Up @@ -77,6 +83,11 @@ def search(
office action documents. You can provide either a direct post_body,
a criteria string, or use convenience parameters.

Every convenience filter is a code filter (document codes, categories,
tech centers, art units). A value that contains spaces, such as
``"rejected OR objected"``, is wrapped in parentheses, so the field
applies to every term.

Args:
criteria: Direct Solr query string (e.g., ``"patentApplicationNumber:14485382"``).
sort: Sort order for results (e.g., ``"submissionDate desc"``).
Expand All @@ -85,6 +96,8 @@ def search(
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.
Any common form matches (``17/765,301``, ``17765301``). Write
each number in an ``OR`` or range expression as 8 digits.
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.
Expand Down Expand Up @@ -132,33 +145,19 @@ def search(
# 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)
final_criteria = all_of(
application_number(
"patentApplicationNumber", patent_application_number_q
),
term("legacyDocumentCodeIdentifier", legacy_document_code_identifier_q),
term("groupArtUnitNumber", group_art_unit_number_q),
term("techCenter", tech_center_q),
term("accessLevelCategory", access_level_category_q),
term("applicationTypeCategory", application_type_category_q),
date_range(
"submissionDate", submission_date_from_q, submission_date_to_q
),
)

if final_criteria is not None:
body["criteria"] = final_criteria
Expand Down
Loading
Loading