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
2 changes: 1 addition & 1 deletion .pdm-python
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/Users/ilo/Devel/ch-api/.venv/bin/python
/Users/ilo/Devel/Release.art/ch-api/.venv/bin/python
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,20 @@ Example of getting company information:

## Pagination

List endpoints return `MultipageList[T]` with `.data` (list) and `.pagination` metadata:
List endpoints return a `MultipageList[T]` with `.data` (tuple) and `.pagination`
metadata. Pass `result_count` to collect more items per call, and advance with
`client.fetch_next_page(page.pagination.next_page)`:

>>> async def search_example(client):
... results = await client.search_companies("tech", result_count=1)
... results = await client.search_companies("tech")
... return len(results.data) >= 1
>>> run_async_func(search_example)
True

`pagination.next_page` is a **self-contained** cursor — it embeds the endpoint and
its arguments, so a fresh process can resume with just the token via
`await client.fetch_next_page(token)` (ideal for stateless servers or agent tools).

## Rate Limiting

The API allows 600 requests per 5 minutes. Use an async rate limiter:
Expand Down
10 changes: 6 additions & 4 deletions docs/sources/api-overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ Key Endpoints
Pagination
==========

List endpoints return ``MultipageList[T]`` with ``data`` (list) and ``pagination`` metadata.
Pass ``result_count`` to control how many items are fetched, and ``next_page`` to continue:
List endpoints return a ``MultipageList[T]`` with ``data`` (tuple) and ``pagination``
metadata. Pass ``result_count`` to collect at least that many items in one call,
advance with ``Client.fetch_next_page`` (the basis for stateless resume), and
use ``page_size`` to control the underlying per-request size:

>>> async def pagination_example(client):
... page = await client.search_companies("tech", result_count=1)
... page = await client.search_companies("tech")
... print(f"Company: {page.data[0].title}")
... print(f"Has more: {page.pagination.has_next}")
... return True
Expand Down Expand Up @@ -115,7 +117,7 @@ All calls are async and must be awaited:
>>> async def async_example(client):
... company = await client.get_company_profile("09370755")
... print(f"Company: {company.company_name}")
... results = await client.search_companies("Apple", result_count=1)
... results = await client.search_companies("Apple")
... if results.data:
... print(f"Found: {results.data[0].title}")
... return True
Expand Down
72 changes: 50 additions & 22 deletions docs/sources/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Officers
Get information about company officers (directors, secretaries, etc.)::

>>> async def officers_example(client):
... officers = await client.get_officer_list("09370755", result_count=1)
... officers = await client.get_officer_list("09370755")
... return len(officers.data) >= 1
>>> run_async_func(officers_example)
True
Expand All @@ -103,7 +103,7 @@ Access a company's filing history:

.. code:: python

# Get filing history
# Get up to 100 filings (issues several requests as needed)
filings = await client.get_company_filing_history("09370755", result_count=100)
for filing in filings.data:
print(f"Description: {filing.description}")
Expand All @@ -117,9 +117,9 @@ Get information about charges registered against a company:

.. code:: python

# Get charges
charges = await client.get_company_charges("09370755", result_count=100)
for charge in charges.data:
# Get charges (single response, not paginated)
charges = await client.get_company_charges("09370755")
for charge in charges.items or []:
print(f"Charge Number: {charge.charge_number}")
print(f"Created: {charge.created_on}")
print(f"Status: {charge.status}")
Expand All @@ -136,7 +136,7 @@ Company Search
Search for companies by name::

>>> async def search_companies_example(client):
... results = await client.search_companies("Apple", result_count=1)
... results = await client.search_companies("Apple")
... return len(results.data) >= 1
>>> run_async_func(search_companies_example)
True
Expand Down Expand Up @@ -192,36 +192,62 @@ Search for disqualified officers:
Working with Pagination
=======================

Many API endpoints return paginated results as :class:`ch_api.types.pagination.types.MultipageList`, a simple value object with ``data`` (list of items) and ``pagination`` (cursor metadata).
Many endpoints return a :class:`ch_api.types.pagination.types.MultipageList`: an immutable value object with ``data`` (this call's items, a tuple) and ``pagination`` (cursor metadata). Pass ``result_count`` to collect at least that many items in one call (the client issues multiple ``page_size`` requests as needed).

Fetching a page::

>>> async def lazy_loading_example(client):
... results = await client.search_companies("tech", result_count=1)
... results = await client.search_companies("tech")
... return len(results.data) >= 1
>>> run_async_func(lazy_loading_example)
True

Fetching multiple pages
-----------------------

Use ``result_count`` to fetch more items in one call, or loop with ``next_page``:
Pass ``result_count`` to collect more items in one call, then walk the rest of
the result set with ``client.fetch_next_page``:

.. code:: python

# Fetch at least 100 items (may make multiple underlying requests)
# Collect at least 100 items (may issue several underlying requests)
page = await client.search_companies("tech", result_count=100)
for company in page.data:
print(company.title)

# Manual cursor-based paging
while True:
for company in page.data:
print(company.title)
if not page.pagination.has_next:
break
# fetches the next batch of result_count items
page = await client.fetch_next_page(page.pagination.next_page)

# Or resume statelessly with the opaque cursor token (see "Restarting from a
# token" below) — endpoints themselves take no next_page argument
page = await client.search_companies("tech", result_count=25)
while page.pagination.has_next:
page = await client.search_companies(
"tech",
next_page=page.pagination.next_page,
result_count=25,
)
page = await client.fetch_next_page(page.pagination.next_page)

Restarting from a token (servers / agent tools)
-----------------------------------------------

``pagination.next_page`` is **self-contained**: it embeds the endpoint and its
arguments, so a separate process can resume from just the token via
:meth:`~ch_api.api.Client.fetch_next_page` — no in-memory state, no re-supplying the
query. Ideal for an async service or agent tool that returns a page plus a cursor
and continues on a later, independent request:

.. code:: python

# First request: return a page and a cursor to the caller
page = await client.search_companies("tech", page_size=20)
payload = {"items": [c.model_dump() for c in page.data],
"next": page.pagination.next_page} # opaque token

# ... later, a fresh request arrives carrying only `next` ...
page2 = await client.fetch_next_page(payload["next"])

Only the 12 paginated endpoints can be resumed this way; a token naming anything
else is rejected. Configure a :class:`~ch_api.types.pagination.types.PageTokenSerializer`
on the client to sign or encrypt the token before it leaves your service.

.. _usage.rate-limiting:

Expand Down Expand Up @@ -541,6 +567,8 @@ Pagination Issues

If pagination isn't working as expected:

- Use a regular ``for`` loop over ``result.data`` (it's a plain list)
- Pass ``result_count`` to fetch more than one page's worth of items
- Use ``result.pagination.next_page`` to fetch subsequent pages manually
- Use a regular ``for`` loop over ``result.data`` (it's a tuple)
- Pass ``result_count`` to collect more items per call; ``page_size`` controls the
underlying per-request size
- Call ``await client.fetch_next_page(result.pagination.next_page)`` to fetch the
next batch
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
Expand Down
29 changes: 16 additions & 13 deletions src/ch_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
>>> # Search for companies
>>> @run_async_func
... async def search_companies_example(client):
... results = await client.search_companies("Apple", result_count=25)
... results = await client.search_companies("Apple")
... for company in results.data:
... print(f"Found: {company.title}")
...
Expand All @@ -30,7 +30,7 @@
>>> # Get officers
>>> @run_async_func
... async def get_officers_example(client):
... officers = await client.get_officer_list("09370755", result_count=100)
... officers = await client.get_officer_list("09370755")
... for officer in officers.data:
... print(f"Officer: {officer.name}")
Officer: ...
Expand Down Expand Up @@ -142,24 +142,27 @@

Pagination
----------
Search and list endpoints return ``MultipageList[T]`` with a ``data`` list
and ``pagination`` metadata. Pass ``result_count`` to fetch more items in one
call, and ``next_page`` to continue from a previous response::
Search and list endpoints return a ``MultipageList[T]`` with a ``data`` tuple and
``pagination`` metadata. Pass ``result_count`` to collect at least that many
items in one call (issuing multiple underlying requests if needed), advance with
``client.fetch_next_page``, and use ``page_size`` to control the underlying
per-request size::

>>> @run_async_func
... async def pagination_example(client):
... page = await client.search_companies("Apple", result_count=25)
... # page.data is a plain list — iterate with a regular for loop
... page = await client.search_companies("Apple")
... # page.data is a tuple — iterate with a regular for loop
... assert len(page.data) >= 1
... # Fetch next page using the cursor
... # Fetch the next page via the client and this page's token
... if page.pagination.has_next:
... page2 = await client.search_companies(
... "Apple",
... next_page=page.pagination.next_page,
... result_count=25,
... )
... page2 = await client.fetch_next_page(page.pagination.next_page)
...

``pagination.next_page`` is a **self-contained** cursor: it embeds the endpoint and
its arguments, so a fresh process can resume with only the token via
``client.fetch_next_page(token)`` — ideal for stateless servers or agent tools. Pair
it with a ``PageTokenSerializer`` to sign or encrypt the token on the wire.

Exception Handling
------------------
Handle API errors with custom exceptions::
Expand Down
2 changes: 1 addition & 1 deletion src/ch_api/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.2.3"
__version__ = "2.0.0"
70 changes: 70 additions & 0 deletions src/ch_api/_paginate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""The ``@paginated`` decorator and the task-local channel it feeds.

Internal; not part of the public API.
"""

import contextvars
import functools
import inspect
import typing

import pydantic

from .types.pagination.types import _PageState

_PaginatedFn = typing.TypeVar("_PaginatedFn", bound=typing.Callable[..., typing.Awaitable[typing.Any]])


#: Task-local channel from :func:`paginated` to the fetch helpers, carrying the
#: active call's endpoint name and arguments (its position fields are unset here;
#: the helpers add those when stamping the ``next_page`` token). Task-local so
#: concurrent paginated calls on one client don't clash. An empty ``endpoint``
#: means no paginated call is active.
_resume_ctx: contextvars.ContextVar[typing.Optional[_PageState]] = contextvars.ContextVar(
"ch_api_resume_ctx", default=None
)


def current_resume_state() -> _PageState:
"""The active ``@paginated`` call's :class:`_PageState`, or an empty one."""
return _resume_ctx.get() or _PageState()


def paginated(*, exclude: typing.Collection[str] = ("self",)) -> typing.Callable[[_PaginatedFn], _PaginatedFn]:
"""Decorate an async ``Client`` method that returns a ``MultipageList``.

* Validates arguments via :func:`pydantic.validate_call` (schema from the
method's annotations).
* Publishes the endpoint name and arguments on :data:`_resume_ctx` so the
fetch helpers can build a replayable ``next_page`` token.

Args:
exclude: Argument names left out of the resume token's ``params``
(cursor position is tracked separately). Defaults to ``self``.
"""
excluded = set(exclude)

def decorate(func: _PaginatedFn) -> _PaginatedFn:
endpoint = func.__name__
sig = inspect.signature(func)
validated = pydantic.validate_call(func)

@functools.wraps(func)
async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
params = {name: value for name, value in bound.arguments.items() if name not in excluded}
ctx_token = _resume_ctx.set(_PageState(endpoint=endpoint, params=params))
try:
result = await validated(*args, **kwargs)
finally:
_resume_ctx.reset(ctx_token)
return result

#: Marks the method as a resumable paginated endpoint. ``Client.fetch_next_page``
#: only re-dispatches to methods carrying this flag, so the allowlist can never
#: drift out of sync with the set of ``@paginated`` methods.
wrapper._ch_paginated = True # type: ignore[attr-defined]
return typing.cast(_PaginatedFn, wrapper)

return decorate
Loading
Loading