API and compatibility policy¶
Scope¶
This document defines clickwork's public API surface and the
compatibility guarantees that apply to it. It covers which symbols
callers can depend on, what constitutes a breaking change, how long
deprecated symbols stick around, and which dependency and interpreter
versions clickwork commits to supporting. The policy is in effect
from 1.0.0 onward; the 1.0 cut was explicitly allowed to break 0.2.x
where the break corrected a genuine design mistake, and those
breakages are catalogued in MIGRATING.md.
Related docs: MIGRATING.md for the 0.x to 1.0
upgrade path (covers every breaking change introduced in 1.0),
and SECURITY.md for the security properties
clickwork asserts about its public surface.
Public API surface¶
The public surface is defined by two things: the names re-exported from
clickwork/__init__.py's __all__, and the documented submodules that
callers are expected to import directly. Anything else is private, even
if Python's import system happens to make it reachable.
Top-level names (re-exported via clickwork.__all__)¶
| Name | Kind | One-line description |
|---|---|---|
create_cli |
function | Build a CLI with global flags and plugin discovery. |
add_global_option |
function | Install a Click option at root plus every nested group and subcommand. |
load_config |
function | Load layered TOML config for custom config scenarios. |
CliContext |
dataclass | Typed context object passed to every command. |
pass_cli_context |
decorator | Decorator for commands; handles the nested-group footgun. |
Secret |
class | Redacted wrapper for sensitive config values. |
CliProcessError |
exception | Raised when a subprocess exits non-zero. |
ConfigError |
exception | Raised when config loading or schema validation fails. |
PrerequisiteError |
exception | Raised when a required tool is missing or unauthenticated. |
HttpError |
exception | Raised when an HTTP call returns non-2xx or fails validation. |
normalize_prefix |
function | Normalize a CLI name to an env-var prefix (e.g. my-tool to MY_TOOL). |
platform_dispatch |
decorator | Route a command to a per-OS implementation. |
get, post, put, delete |
functions | HTTP verb helpers re-exported from clickwork.http. |
platform, http, testing |
submodules | Documented submodules exposed on the package. |
Documented submodules¶
clickwork.http, clickwork.platform, and clickwork.testing are
re-exported from the top-level package, so callers can reach them as
attributes (clickwork.http.get, etc.) after import clickwork.
clickwork.config is NOT re-exported at the top level for historical
reasons; reach it via an explicit import clickwork.config or
from clickwork import config / from clickwork.config import load_config.
Names exported by each submodule are part of the public surface when
they appear in the submodule's own docstring and public symbols; names
prefixed with an underscore are private.
| Submodule | Public surface | Import style |
|---|---|---|
clickwork.http |
get, post, put, delete, HttpError. URL allowlist, no-redirect security, JSON auto-parse. |
attribute on clickwork |
clickwork.platform |
platform_dispatch, dispatch, is_linux, is_macos, is_windows, find_repo_root. |
attribute on clickwork |
clickwork.testing |
run_cli, make_test_cli. Helpers for writing plugin test suites against a real CLI. |
attribute on clickwork |
clickwork.config |
load_config, load_env_file, ConfigError. Layered TOML and dotenv helpers. |
explicit import required |
Protocol-level surfaces¶
Some contracts are not Python symbols but still count as public because external code depends on their shape. These get the same semver promise as the symbol surface.
- The
clickwork.commandsentry-point group used by installed plugins. The group name and the expected shape of a registered entry point (a ClickCommandorGroupexposed ascli) are stable. - The layered config precedence order (environment variables, then
[env.<name>], then[default], then user-level config, then schema defaults). Changing the precedence is a breaking change. SeeGUIDE.md#config-precedencefor the authoritative ordered table and a worked example. - Global flag names installed by
create_cli()(--verbose,--quiet,--dry-run,--env,--yes). Removing or renaming one is breaking.--version/-Vis also reserved at this level, but is only installed when the caller passesversion=orpackage_name=tocreate_cli(); its presence is opt-in so existing CLIs opt in at their own cadence. - The re-invocation semantics of
setup_logging()— called internally bycreate_cli()on every CLI invocation. Re-invocation in the same process (test suites that drive a CLI viaCliRunnermultiple times, long-running hosts that import a clickwork CLI module repeatedly) is idempotent for handler identity and live for level: a second call never stacks a duplicate clickwork-owned handler, but a second call with a differentverbose/quietargument DOES update the level on both the logger and the clickwork-owned handler. Changing either half of this contract — stacking a duplicate, or making the second call a no-op that ignores the new verbosity — is a major-version break. The identity check relies on a private_clickwork_ownedmarker attribute on the handler; the marker itself is an implementation detail (underscore-prefixed) and is not part of the public surface.
Private and unstable¶
The compatibility promise does not cover:
- Leading-underscore names at any level.
_types,_logging, any module-private helper. If you imported it, you are on your own. This follows standard Python convention and exists so the framework can refactor its internals without needing a major-version bump every time. - The reserved
clickwork._internalnamespace. This submodule does not exist yet. The name is reserved for future internal-only helpers that happen to live in a dedicated namespace (rather than as underscore-prefixed symbols). Anything that ever lands underclickwork._internalis explicitly private regardless of whether the submodule or its contents carry an underscore prefix. - Symbols not re-exported by
__all__. Even if a name is importable because it happens to live in a public submodule, if it is not listed in that submodule's documented public surface or the package-level__all__, assume it is an implementation detail. Example: the internal loader path behind the entry-point protocol is not API. - Error message wording. Exception types are stable; the exact human-readable text is not. Do not assert against error strings in downstream tests.
Compatibility promise (post-1.0)¶
clickwork follows semantic versioning. Once 1.0.0 ships, breaking changes to the public surface require a major version bump. This section defines precisely what "breaking" means so neither we nor callers are guessing.
What counts as breaking (major bump required)¶
- Removal. A public symbol goes away.
- Rename. A public symbol's import path changes without a shim.
- Signature change. Adding a required positional argument, removing an argument, changing an argument's type in a way the existing caller can't satisfy, or reordering positional arguments.
- Semantic change. The same call with the same arguments starts doing something observably different (different return value shape, different side effects, different exceptions raised).
- Protocol change. Renaming the entry-point group, changing the precedence order of layered config, or renaming a global flag.
What does not count as breaking (minor or patch is fine)¶
- Docstring edits, type-stub tightening that is still structurally compatible, internal refactors that leave the public surface alone.
- New public symbols, new optional keyword arguments with safe defaults, new submodules.
- Bug fixes that align behavior with documented intent. If the docs
said "raises
ConfigErroron unknown key" and the code didn't, the fix is not a breaking change even though some caller's test changed. The reverse also holds: if the docs are wrong and the code is right, fixing the docs is not a breaking change. - New global flags on
create_cli()as long as they do not collide with names a subcommand might reasonably use. (add_global_option's collision semantics cover this case for consumer-defined flags.)
Deprecation policy¶
Public symbols do not disappear without warning. A deprecated symbol
stays available for at least one full minor release cycle before
removal. Concretely: a symbol deprecated in 1.1 is removed no earlier
than 1.2, giving callers at least one version of overlap where the
symbol still works and also emits a DeprecationWarning.
Deprecations use the deprecated(since, removed_in, reason) decorator
that lives at clickwork._deprecated.deprecated. The module is
intentionally underscore-prefixed and is NOT re-exported from
clickwork/__init__.py; plugin authors should not import from it.
The decorator is an internal tool clickwork uses on its own public
surface; it exists so every deprecation emits a consistent warning
with a pointer to the replacement. The warning fires on the first
call to the deprecated symbol (never at import time) and is
deduplicated per symbol using a module-qualified key, so each
deprecated name warns exactly once per process regardless of how many
times it's invoked. Every warning message begins with a
clickwork: prefix so downstream test suites can filter narrowly via
pytest's message-field regex (for example,
filterwarnings = ["ignore:clickwork\\::DeprecationWarning"] (TOML double quotes are required so the \\: escape resolves to \:, the regex-escape for the : field separator)).
Filtering by the module field is not reliable here, because
stacklevel=2 attributes the warning to the caller's module rather
than to clickwork.
The 1.0 release itself may deprecate symbols that existed in 0.2.x,
but it won't remove them in the same release. Anything removed in 1.0
had to have been deprecated in a 0.x release or was never public to
begin with. Breaking changes in the 0.x to 1.0 transition are
catalogued in MIGRATING.md (Wave 4, issue #56).
Click version range¶
clickwork declares click>=8.2 with no upper bound. The floor is
8.2 because the testing guidance in GUIDE.md (assert on
result.stdout and result.stderr directly) requires Click 8.2's
independent stream population; on 8.1 a default CliRunner() mixes
streams and result.stderr raises ValueError. See Click's
changelog for
the 8.2 release notes.
There is no upper bound, and this is a deliberate choice. Pinning
click<9 (or any future major) creates a dependency-resolution
ratchet: the day Click ships a new major, every resolver trying to
install clickwork alongside another package that has already moved to
the new major gets an unsolvable constraint, and clickwork becomes
uninstallable in that environment until we ship a fix release. That
is strictly worse than the alternative, which is silent breakage we
didn't predict. Silent breakage surfaces as a real test failure or a
real bug report; a ratchet surfaces as "I can't install your library
at all," which is harder to diagnose and blocks downstream work.
What we do instead:
- CI (see issue #39) runs a "latest Click" matrix job separate from the pinned-Click lockfile job, so a breaking Click release surfaces on clickwork's own CI the moment it lands on PyPI.
- When a Click major does break us, we ship a fix release (a patch or minor, depending on whether the break required API changes on our side), not a retroactive upper bound. The upper bound stays off.
Reference: the Click documentation is the authoritative source for what Click itself guarantees across majors.
Python version support¶
Floor: requires-python = ">=3.11".
The floor is 3.11 because that is the oldest still-supported CPython
release that ships tomllib (PEP 680) in the standard library, which
clickwork.config uses to parse layered config without adding a
third-party dependency. Dropping to 3.10 would mean either taking a
tomli dependency just to parse config or writing a fallback import
shim; the marginal gain in user coverage does not justify either,
especially since 3.10 is already on its CPython EOL glide path.
Secondary reasons the 3.11 floor is comfortable (not load-bearing
today, but available to us within the supported range as clickwork
evolves): PEP 654 exception groups, PEP 673 Self, and the broader
typing improvements that let us avoid typing_extensions in most
annotations. These are not currently used in the codebase, so do not
treat them as binding contracts — tomllib is the one concrete
dependency that pins the floor.
Ceiling: none. clickwork follows the same "no upper bound, CI covers the matrix" discipline as with Click. Each new CPython release is added to CI (see issue #39) so regressions surface immediately.
Deprecation runway for dropping a Python minor: we will not drop
support for a Python minor earlier than 18 months after CPython's
own EOL for that minor. Concretely, CPython 3.11's EOL is scheduled
for 2027-10, so the earliest clickwork release that drops 3.11 is
2029-04. Callers also get at least two clickwork minor releases
of warning (via deprecation notices in the changelog and a
DeprecationWarning emitted from create_cli() / the first public
API call, not at package import time) before the drop lands.
Warning-emission discipline: we avoid emitting DeprecationWarning
at import time because many downstream test suites run with
filterwarnings = ["error"], and an import-time warning would break
those suites even for callers who aren't touching the deprecated
surface. Warnings fire from the specific entry points that trigger
the deprecated behavior (e.g. inside create_cli() once per CLI,
or from the deprecated function itself). Callers who want to silence
deprecations in their own test runs can add a targeted message-regex
entry such as
filterwarnings = ["ignore:clickwork\\::DeprecationWarning"] (TOML double quotes are required so the \\: escape resolves to \:, the regex-escape for the : field separator) (the
second field is a regex against the warning text, and the
clickwork: prefix on every message is what makes this match). The
module-field form ignore::DeprecationWarning:clickwork looks
plausible but does not actually match, because stacklevel=2
attributes the warning to the caller's module rather than to
clickwork.
The 18-month window is deliberately generous. Enterprise and Linux-distribution Python environments lag upstream by years; a shorter runway punishes the callers most likely to have other constraints blocking a Python upgrade. Eighteen months past EOL is long enough that staying on the dropped minor is a conscious choice, not an oversight.