This file is for AI coding agents (Claude Code, Cursor, Cline, Aider, Windsurf, GitHub Copilot Workspace) that open this directory and want to extend the Convilyn client SDK without breaking it. Read this first; it documents the invariants that the test suite will defend.
sdk/consumer-python/ # PyPI: convilyn (was sdk-client/ before R5, sdk-consumer/ before the sdk/ monorepo layout)
├── pyproject.toml # one source of truth for deps + scripts
├── docs/
│ ├── README.md # PyPI landing page
│ └── QUICKSTART.md # 5-min new-user guide
├── examples/ # runnable Python + shell examples
├── src/convilyn/
│ ├── __init__.py # public surface — only re-exports
│ ├── client.py # AsyncConvilyn root client
│ ├── sync_client.py # Convilyn sync facade
│ ├── exceptions.py # ConvilynError hierarchy (frozen contract)
│ ├── types.py # File / ConvertJob / ResultFile / JobError
│ ├── resources/ # one file per resource (files, convert, ...)
│ ├── cli/ # one file per sub-command (convert, doctor, api, goals)
│ ├── _internal/ # NOT public — http, auth, resilience
│ └── _version.py
└── tests/ # one test file per module; respx for HTTP
Anything reachable as from convilyn import X is public and follows
semver. Treat convilyn._internal.* as private — agents can read it,
but should not import from it in new resources or examples. The
recipe for "I need transport behaviour I can't get from the public
API" is to add a Protocol seam in _internal/, not to reach across
the boundary in a resource.
These are the extension points. Use them; do not duplicate them.
| Seam | Purpose | Where |
|---|---|---|
AuthStrategy Protocol |
Plug a credential carrier other than API key | _internal/auth.py |
AuthStrategy.bearer_token() |
Surface the raw token for non-HTTP transports (WebSocket subprotocol, etc.) | _internal/auth.py |
RetryPolicy Protocol |
Replace retry cadence (linear, decorrelated jitter, etc.) | _internal/resilience.py |
OutputRenderer Protocol |
Add new CLI output formats (YAML, table, …) | cli/_output.py |
_build_client factory |
Inject a mocked Convilyn in CLI tests |
cli/convert.py, cli/goals.py |
HTTPClient.raw_request |
Issue an HTTP request without >=400 raising |
_internal/http.py |
_do_request_with_retry |
Shared retry loop — do not re-implement | _internal/http.py |
- Create
src/convilyn/resources/goals.pywithAsyncGoals(the async-primary class) andGoals(the sync wrapper) — follow the shape offiles.pyandconvert.py. InjectHTTPClientvia the constructor; never importhttpxdirectly. - Add
client.goals = AsyncGoals(self._http)inclient.py::AsyncConvilyn.__init__and the matching line insync_client.py::Convilyn.__init__. - Re-export new models / exceptions from
convilyn/__init__.py. - Add a 4-category test file in
tests/test_goals.py(logic / boundary / error / object-state) using respx for HTTP mocks. - Document the new resource in
docs/QUICKSTART.mdand add anexamples/0X_*.pyrunnable snippet.
- Create
src/convilyn/cli/<name>.pywith a Click command function and dedicated helpers (parsers, renderers). - Register with
cli.add_command(<name>_command, name="<name>")incli/main.py. - Reuse
_build_clientfor any SDK call so tests can mock it. - Reuse
make_rendererfromcli/_output.pyfor--json/ human output — do not print JSON inline in your command. - Add a 4-category test file in
tests/test_cli_<name>.pywithCliRunner+ respx.
convilyn.local converts files on the user's machine. It has no client, no
transport, and no credential, so several conventions above do not apply to
it. Read this before "fixing" any of them.
It is synchronous, and that is deliberate. The rest of this SDK is
async-primary because it is IO-bound over HTTP — an await there lets the loop
run something else while the network answers. Conversion is CPU- and
subprocess-bound, which inverts the argument: an async def running pdfplumber
inline would satisfy the letter of the convention while blocking the loop for
seconds. aconvert / aconvert_many are thin asyncio.to_thread wrappers, and
they require the synchronous implementation to be the real one.
tests/unit/local/test_api.py::TestAsyncWrappers pins both halves.
Layering: a leading underscore means internal, and there are no exceptions.
Public are __init__, types, errors, api. Internal are _probe (is this
package importable / where is this executable), _tools (what the external
programs are and where they live), _run (how to invoke them), _routes (the
route table and the availability join), and _engine (generated). A reader
should never have to check a denylist to know which side a module is on — if you
add a module, its name states the answer.
_tools and _run are separate on purpose. "Do I have LibreOffice" is asked
whenever somebody lists what this machine can convert; "run LibreOffice" happens
only once a conversion starts. The split lets _routes answer the first without
being able to spawn a subprocess, and lets the generated bridge invoke the
second without importing the discovery tables.
_engine/ is GENERATED. Never edit it. It is projected from Convilyn's
server-side conversion engine by scripts/oss/project_local_engine.py and
regenerated whenever that engine changes; a hand edit is silently lost at the
next regeneration. Every file carries a DO-NOT-EDIT header, a drift gate in
scripts/ci/sdk_local_ci.py compares the tree against a fresh projection, and
tests/packaging/test_offline_engine_packaging.py asserts the headers. To
change conversion behaviour, change it upstream and re-project.
No optional dependency may be imported at module scope, anywhere reachable
from convilyn/__init__.py or cli/main.py. The release pipeline installs the
wheel with no extras and runs convilyn --version; an eager parser import
would break that on a user's machine rather than here. Extractors are imported
lazily inside their registry entry, and the property is held by a packaging
test rather than by memory.
Availability is data, not an exception. capabilities() and plan() never
raise — a missing dependency is a fact about the machine, and the caller asking
is the one who has not decided what to do about it yet. Each Route carries
unavailable_reason, a full sentence naming what is missing and how to get it,
authored once in local/routes.py; the error classes quote it rather than
composing their own.
Probes are injectable, and tests must inject them. This suite runs with the
extras installed and, on some machines, with LibreOffice installed — so the
"missing dependency" arm that users on a fresh install actually meet would
otherwise never execute. Inject probe= (packages) and find= (external
tools). Note that injecting the lower-level which is not enough: the lookup
falls through to the platform's install locations and finds the real one.
- No real backend calls in unit tests. Use respx to mock HTTP and
tmp_pathfor filesystem. - Four categories per behaviour: logic, boundary, error, object-state. The unit-testing skill documents this in detail; the existing test files are good models.
- Assert on observable behaviour, not internal calls — assert which HTTP routes were hit, what exit code came back, what fields the response carried. The orchestration can evolve without test churn.
- Click 8.3+ removed
mix_stderr— do not pass it torunner.invoke.
from __future__ import annotationsat the top of every module so forward references just work.- Pydantic v2 with
populate_by_name=Trueso SDK fields use Python snake_case while the wire stays camelCase. - Frozen Pydantic models for public response types (
File,ConvertJob,ResultFile,JobError). - Behaviour on the resource, data on the model — the OpenAI / Stripe convention. Do not put HTTP calls on the data models.
- Importing from
convilyn._internaloutsideconvilyn._internalandcli/(which is intentionally tight-coupled to the SDK's own transport). - Adding a hidden retry loop inside a resource — use the one in
_do_request_with_retry. - Calling
time.sleepanywhere in async code paths (useawait asyncio.sleep). - Bypassing
_build_clientin CLI sub-commands. - Hand-rolling
Authorizationheader —AuthStrategy.headers()owns that. - Changing public field names (
filename,size,content_type) without a major version bump.
- Read the relevant test file — the test names spell out the contract for the module.
convilyn doctorfor environment / connectivity issues.convilyn api <METHOD> <PATH> --jsonto inspect a backend endpoint the SDK has not wrapped yet.- Open an issue with a minimal repro and the output of
convilyn doctor.