hotcodepush, the typed Python client for the HotCodePush REST API, modeled on Capawesome's cloud-python and mirroring hotcodepush-node method for method.
Stack: Python 3.11 or later, httpx (sync) and pydantic v2 at runtime and nothing else; uv for the venv, the lockfile and CI, hatchling for the build, ruff, mypy strict and pytest.
The API contract is planned in the private handbook repo, checked out beside this one by maintainers: ../handbook/docs/api.md, its Conventions and Errors sections above all.
When the code and the contract disagree, stop and surface it; never improvise.
The client is validated against the API's OpenAPI document, the contract; see The OpenAPI snapshot.
openapi.json the snapshot of the API's OpenAPI document, committed
scripts/sync_openapi.py fetches the document into openapi.json
src/hotcodepush/__init__.py the public exports
src/hotcodepush/client.py HotCodePush: the options and one attribute per top-level resource
src/hotcodepush/_http.py the one place that sends a request: the headers, the timeout, send_with_retry, JSON in and out
src/hotcodepush/_upload.py the single-or-parts rule of an upload
src/hotcodepush/_types.py NOT_GIVEN, the default of every optional argument
src/hotcodepush/exceptions.py HotCodePushError and the parsing of the one error shape
src/hotcodepush/models.py every type a method takes or returns, derived from the snapshot by hand
src/hotcodepush/resources/ one module per resource class
tests/test_<resource>.py the unit tests of one resource module's methods, through tests/stub_api.py; _upload.py and models.py are tested through them
tests/test_http.py the unit tests of _http.py and the client's options
tests/test_exceptions.py the unit tests of exceptions.py
tests/test_openapi.py the contract test: every resource method against the snapshot
tests/test_integration.py the client against a running API, skipped unless HOTCODEPUSH_API_BASE_URL is set
tests/stub_api.py StubApi, the httpx.MockTransport every unit test sends through
tests/openapi_document.py the snapshot's operations and the examples its schemas describe
Resources mirror the paths: /v1/organizations/{organizationId}/apps is organizations.apps, /v1/apps/{appId}/channels/{channelId}/releases is apps.channels.releases; a resource nests on the one its path sits under, and a path collection is one class.
The methods are the Node client's, their names in snake_case: deleteMany is delete_many, downloadCsv is download_csv, countApps is count_apps.
Module names are the Node file names in snake_case, channel-releases.ts being resources/channel_releases.py.
Tests stub the transport through tests/stub_api.py, answering with the examples tests/openapi_document.py derives from the snapshot; nothing calls the real API but the integration test, run by hand.
| Command | Does |
|---|---|
uv sync |
the venv from uv.lock, the dev tools included |
uv run ruff format . |
formats the code |
uv run ruff format --check . |
the format check |
uv run ruff check . |
the lint |
uv run mypy |
the strict type check of src, tests and scripts |
uv run pytest |
the tests, the contract test included |
uv build |
the sdist and the wheel into dist/ |
uv run scripts/sync_openapi.py |
<HOTCODEPUSH_API_BASE_URL>/openapi into openapi.json |
Run uv run ruff format . before every commit; the format check, lint, type check, tests and build must pass, and CI runs them on Python 3.11 to 3.14 on every pull request and push to main, with uvx twine check dist/* on the build.
uv sync --locked fails when uv.lock is stale; commit it with every dependency change.
- Auth is
Authorization: Bearer <token>; the token falls back toHOTCODEPUSH_TOKENand stays optional, since/healthneeds none. - Every request sends
X-HotCodePush-Client: <name>/<version>,python/<this package's version>unless the caller passes its ownclient;User-Agentgoes out only when the caller setsuser_agent, since a session keeps it and the sessions page names the client by it. - Every error is one JSON shape,
code,message,details;HotCodePushErrorcarries them and the status verbatim, and a body without the shape becomesunexpected_response. - Every call goes through
HttpClient, never a barehttpxcall: sixty seconds per attempt,JSON_TIMEOUT_SECONDSor the caller'stimeout, and ten minutes for a binary upload,UPLOAD_TIMEOUT_SECONDS, each httpx's limit on every phase of the attempt, the connect, each read and write and the pool;send_with_retrymakes three attempts, backing off exponentially from 500 ms, on a transport error, 408, 429 and 5xx; a 429 or a 503 waits itsRetry-Afterinstead, at most sixty seconds, the rate-limit window. - Every creating
POST— the eight operations whose OpenAPI document lists theIdempotency-Keyheader: organizations, invitations, apps, channels, releases, rollbacks, bundles and binaries — goes throughfetch_creating_post, which sends the caller'sidempotency_keyor a UUID generated per call and kept across its retries. A checkout and a customer portal session carry no key and are never retried, since each call creates another Polar object. - Any other
POSTis retried only where a repeat is safe,is_retryable=True:pause,resume, the subscription'scancelanduncancel, a bundle'scomplete, a channel's bulkrevoke, a signing key'screate, the SSO verification, the two multipart starts and a file upload's completion; a pack's completion is never retried, since a repeat answersnot_foundfor an upload R2 already closed. - Lists take
limitandoffset;relationsandidsare lists sent as comma lists,?relations=user,?ids=a,b, and any other list repeats its parameter,?attribute=a&attribute=b. meis accepted wherever a{userId}appears; the client passes it through.- The
/v1/auth/*slice is Better Auth's and outside the document; its client lives in the CLI and the console, never here. - Every binary
PUTgoes throughfetch_upload: the body isbytesor a file object with itscontent_length, streamed withContent-Lengthand the documentedContent-Type; bytes are retried, a file object is read once and never retried. - An upload's bytes above
SINGLE_UPLOAD_LIMIT_BYTES, 64 MiB, go up in parts ofPART_SIZE_BYTES, 10 MiB, numbered from 1, then complete, throughupload_in_parts; a failed part or completion deletes the upload.
- Every argument is keyword-only and snake_case, in alphabetical order; the path parameters are arguments like any other,
app_id=…. - An optional argument defaults to
NOT_GIVENand is left out of the request;Noneis sent asnull, so a nullable field is typedX | None | NotGiven, any otherX | NotGiven. - The body and the query are dicts whose keys are the API's camelCase names, written out,
{"isProtected": is_protected}, so a name greps againstopenapi.json. - Every path goes through
resolve_pathwith the snapshot's template verbatim,resolve_path("/v1/apps/{appId}", appId=app_id). - A method returns a model or a list of models, as the API answers; a status-only answer returns
Nonethroughfetch_status. - A model is a
HotCodePushModelnamed after the API's entity, its fields the snapshot's properties in snake_case: required ones without a default, nullable onesX | None, a relationX | None = None; a date-time is adatetime, an enum aLiteral, an alias inmodels.pywhen two types share it. - A query flag documented as
"true" | "false"is abool. - After a resource's
listmethod, an annotation writesbuiltins.list, since the method shadows the builtin in the class body. - A method whose operation the document marks with
sessionAuthcarriesAn API token answers `forbidden`; sign in with a session.in its docstring; the contract test holds the note to the document.
The API's /openapi document is the contract, and openapi.json is its committed snapshot, the same bytes as hotcodepush-node's, so the package builds and CI checks without the API.
A schema change breaks tests/test_openapi.py, which calls every resource method once with every option it can send and answers it with the example of its operation's documented response: it fails when a method sends a path, method, query parameter, body field or content type the snapshot lacks, when it sends an Idempotency-Key where the operation documents none or none where it does, when a response property lands in a model's model_extra or fails its validation, when the session note and sessionAuth disagree, when a resource method has no call in CALLS, and when an operation has no method.
When the API changes:
- Run the API locally, as
apps/api-worker/README.mdin the monorepo says. HOTCODEPUSH_API_BASE_URL=http://localhost:8787 uv run scripts/sync_openapi.py.- Fix what no longer passes, and add the resource methods for new endpoints, their models and their calls in
CALLS. - Commit
openapi.jsonwith the change; it is never edited by hand.
The integration test runs by hand against that API, started with ENVIRONMENT=dev and DEV_DATABASE_URL in its .dev.vars so the mail lands in its log: sign up through POST /v1/auth/sign-up/email, send the logged link's token to GET /v1/auth/verify-email?token=, sign in through POST /v1/auth/sign-in/email and take the set-auth-token header, then HOTCODEPUSH_API_BASE_URL=http://localhost:8787 HOTCODEPUSH_API_TOKEN=<bearer> uv run pytest tests/test_integration.py.
- A name says what the function does on first read: the verb, the object and, where it matters, the qualifier.
- Prefixes:
fetchfor HTTP,resolvefor derivations without I/O. - Result variables carry the past participle of their operation:
fetched_channel,resolved_error. - Alphabetical ordering within a scope: imports, dict keys, keyword arguments, model fields, declarations of one kind.
- Booleans carry
isorhas; a state with a moment is a timestamp such aspaused_at, never a boolean. - Every value of a catalog is lowercase snake_case with no prefix, error codes included:
not_found,unexpected_response; the API's are passed through verbatim, never renamed. - Never
castor# type: ignore; a real check. - Test titles read
test_<verb>_…, the verb in the third person, conditions starting withwhen; one test per request and branch.
Nothing is on PyPI yet: consumers install a commit, pip install git+https://github.com/hotcodepush-team/hotcodepush-python@<sha>, pinning it and bumping it on purpose, never @main.
Commits are conventional commits, since release-please reads them; main is trunk and CI is the gate.
.github/workflows/release.yml runs release-please on every push to main: it keeps a release pull request open, and merging it tags the release, builds the distributions and publishes them to PyPI through trusted publishing, with no API token anywhere.
The version lives in src/hotcodepush/_version.py, which release-please bumps; the first release is 0.1.0, the Python strategy's initial version, and bump-minor-pre-major with bump-patch-for-minor-pre-major keeps every release on 0.x until 1.0.0 is decided.
A release pull request opened with the workflow's own token starts no CI run; close and reopen it to run the checks.
The owner's one-time steps before the first release pull request is merged:
- Create the GitHub environment
pypiin the repository settings. - On pypi.org, add a pending trusted publisher: the project
hotcodepush, the ownerhotcodepush-team, the repositoryhotcodepush-python, the workflowrelease.yml, the environmentpypi.
- No
.mcp.jsonyet: the one server this repo's stack takes is HotCodePush's own,https://mcp.hotcodepush.com/mcp, which does not exist yet; it joins the day it does. .claude/skills/holds the developer skills copied fromhotcodepush-team/.githubwith the skills CLI and pinned inskills-lock.json; update them withnpx skills update..github/copilot-instructions.mdholds the review criteria, the only Copilot-specific file.