Migrating from clickwork 0.2.x to 1.0¶
Who this is for¶
You are already running clickwork 0.2.x in a project and want to upgrade to 1.0. This guide assumes you know the 0.2.x API; it calls out only what changes. If you are new to clickwork, start with GUIDE.md.
tl;dr¶
uv pip install 'clickwork>=1.0,<2'
# or: pip install 'clickwork>=1.0,<2'
uv run pytest # rerun your test suite
Then walk the Breaking changes below. Most callers
need one or zero code changes. Callers who patched private symbols,
asserted on ConfigError for env-var type mismatches, or relied on
clickwork logs appearing twice (once via clickwork's own handler, once
via propagation) need targeted fixes.
The post-1.0 compatibility promise is defined in API_POLICY.md; the full change ledger lives in CHANGELOG.md.
Breaking changes¶
1. Config env-vars coerce to the schema type¶
What changed. In 0.2.x, an env var (always a string at the OS level)
whose schema declared type: int / float / bool raised
ConfigError during load_config() itself -- schema validation runs
once as part of loading, not on each ctx.config.get() call
(ctx.config is a plain dict, so .get() never raises on type
mismatch). In 1.0, load_config() coerces the string to the declared
type before it writes to the merged dict, so downstream
ctx.config.get() calls just see the already-typed value.
Before:
CONFIG_SCHEMA = {
"port": {"type": int, "env": "MY_TOOL_PORT"},
}
# MY_TOOL_PORT=8080 in the environment
load_config(schema=CONFIG_SCHEMA)
# clickwork 0.2.x: ConfigError("port: expected int, got str")
# clickwork 1.0: returns {"port": 8080} (int, coerced)
The coercion table (stdlib-only, no new dependencies):
int:int(value)base 10. Parse failure raisesConfigError.float:float(value). Parse failure raisesConfigError.bool: case-insensitive allowlist.true,1,yes,onbecomeTrue;false,0,no,offbecomeFalse. Anything else (for examplemaybe) raisesConfigError. The allowlist is deliberate: Python's built-inbool("false")returnsTrue, which is a well-known footgun.str: unchanged.
There is also a related tightening worth knowing about even though it is
unlikely to affect real code: a TOML literal of the wrong type is still
rejected, and because bool is a subclass of int in Python, the
schema validator now explicitly rejects port = true for a type: int
key (and the reverse, an integer literal for a type: bool key). In
0.2.x this edge case silently passed the isinstance(value, int)
check.
How to detect it. Your test suite asserts ConfigError for an
env-string plus numeric schema combination and the assertion no longer
fires. Or a caller's code had a try: ... except ConfigError: fallback
that converted the value manually; the fallback branch now stops
running because the happy path succeeds with the coerced value.
How to migrate. Remove the ConfigError assertion. If you
worked around the coercion by using type: str plus a manual
int(ctx.config["port"]), the workaround keeps working unchanged;
switching to native type: int in the schema is optional cleanup.
2. setup_logging() no longer attaches its own stderr handler under host config¶
What changed. In 0.2.x, setup_logging() attached a
StreamHandler to clickwork's logger unconditionally. If the embedding
host application had already configured the root logger (via
logging.basicConfig(), structlog, or similar), every clickwork
log record was emitted twice: once through clickwork's own handler and
once after propagating to the host's root handler.
In 1.0, setup_logging() only attaches a StreamHandler when the root
logger has no handlers (the bare-script case). The clickwork logger
also gets a NullHandler baseline at import time and
propagate=True, so hosts that have configured root logging keep full
control of formatting and destination.
Before (host calls logging.basicConfig(), then clickwork is used):
2026-04-18 12:00 INFO clickwork.cli Discovered command: deploy # via host handler
clickwork.cli: Discovered command: deploy # via clickwork's own handler (duplicate)
After:
How to detect it. Log output from embedded consumers drops from two lines per record to one. Tests that asserted on the count of records captured via a host-installed handler now see half as many.
How to migrate. If you actively wanted the duplicate output (rare),
explicitly install your own StreamHandler on the clickwork logger:
More often, the fix is to update the test expectation: assert on one
record per emit, not two. The public signature of setup_logging is
unchanged, so construction-site code needs no edits.
3. click>=8.2 is required¶
What changed. clickwork 0.2.0 already raised the click floor to
>=8.2, and 1.0 keeps it there with no upper bound. Most consumers
will see no effect here -- you were already on Click 8.2 because
0.2.0 required it. This note exists for projects whose own
pyproject.toml or lockfile independently pinned click<8.2 (for
example, a CI dependency pin that pre-dated the 0.2.0 upgrade and
never got lifted): that pin must come off now so the resolver can
pick up a Click version compatible with clickwork 1.0.
Before:
# in a consumer project's test suite on an old Click
runner = CliRunner(mix_stderr=False) # removed in Click 8.2
result = runner.invoke(cli, [...])
assert result.stderr == "expected error"
After:
from clickwork.testing import run_cli # or a plain CliRunner()
result = run_cli(cli, [...])
assert result.stderr == "expected error" # 8.2 populates .stderr directly
How to detect it. TypeError: __init__() got an unexpected keyword
argument 'mix_stderr' at test collection time.
How to migrate. Remove the mix_stderr=False kwarg. On Click 8.2
and later, result.stdout and result.stderr are populated
independently by default, so the canonical assertion pattern works
with a plain CliRunner(). Prefer clickwork.testing.run_cli, which
also pins catch_exceptions=False so bugs surface as real tracebacks
instead of being swallowed. See also
LLM_REFERENCE.md footgun #3.
4. ctx.require patch target moved to clickwork.prereqs.require¶
What changed. 0.2.0 removed the internal alias
clickwork.cli._require. Tests that patched it to stub prereq checks
need to patch clickwork.prereqs.require instead. This change landed
inside the 0.2.0 release, so if you migrated cleanly to 0.2.x you have
already handled it. It is documented here because the roadmap for 1.0
calls it out and because some 0.2.x consumers may have skipped the
patch-target fix by not running tests that exercised the mock.
Before:
from unittest.mock import patch
with patch("clickwork.cli._require") as mock_require:
mock_require.return_value = None
result = run_cli(cli, ["deploy", "site"])
After:
from unittest.mock import patch
with patch("clickwork.prereqs.require") as mock_require:
mock_require.return_value = None
result = run_cli(cli, ["deploy", "site"])
How to detect it. AttributeError: module 'clickwork.cli' has no
attribute '_require' at the start of a test that uses the patch, or a
mock that silently never fires because the patched symbol exists
somewhere in the import graph but no longer maps to the lookup
CliContext.require performs.
How to migrate. Replace clickwork.cli._require with
clickwork.prereqs.require in every patch() / patch.object()
call. The public surface is clickwork.prereqs.require; the lookup
now goes through a module-level wrapper that re-resolves the name on
every call, so intuitive patching works. Listed in
LLM_REFERENCE.md as footgun #1.
5. click upper bound policy (no pin, by design)¶
What changed. Nothing has been added. pyproject.toml declares
click>=8.2 with no upper bound, and that is deliberate. The 1.0
release commits to this position in writing; it is called out here so
consumers who were considering pinning clickwork alongside
click<9 in their own requirements.txt understand the rationale
before they do.
An upper bound on click creates a dependency-resolution ratchet. The
day Click ships a new major, every resolver trying to install
clickwork alongside another package that already moved to the new
major gets an unsolvable constraint and clickwork becomes
uninstallable in that environment until we cut a fix release. That
outcome is strictly worse than the alternative (silent breakage on a
new Click major), because silent breakage surfaces as a real test
failure or bug report, while a ratchet surfaces as "I can't install
your library at all."
How to detect it. No detection needed; this is a policy statement.
How to migrate. If your downstream project pinned
click<SOMETHING for the sake of clickwork, you can drop that pin.
CI (see .github/workflows/) runs a "latest Click" matrix job on
every clickwork PR, so Click-major breakage lands on clickwork's own
CI the moment it hits PyPI rather than waiting for a user to file a
bug. See API_POLICY.md for the
full rationale.
New opt-in surfaces worth adopting¶
None of these break existing code. They are new capabilities the 0.2.x reader may not know exist yet.
--version / -V flag¶
create_cli() gained two keyword-only kwargs: version= and
package_name=. When either is set, the resulting CLI gets a
--version / -V flag via click.version_option.
# Resolve automatically from your package's installed metadata:
cli = create_cli(name="my-tool", commands_dir=..., package_name="my-tool")
# Or pass the version literal yourself:
cli = create_cli(name="my-tool", commands_dir=..., version="2.3.1")
Precedence: version= wins if both are passed. If package_name=
cannot be resolved via importlib.metadata, create_cli() raises
ValueError at construction time so typos fail loudly instead of
silently disappearing until --version runs in production. When
neither kwarg is set, no --version flag is installed, so existing
CLIs see no change.
strict=True command discovery¶
create_cli() gained a keyword-only strict= flag. With
strict=True, discovery failures (broken import, missing cli
attribute, invalid cli value, duplicate command name, entry-point
load error) aggregate into a single ClickworkDiscoveryError raised
after the full scan. The default stays False so 0.2.x consumers
upgrade with no observable difference.
from clickwork import create_cli, ClickworkDiscoveryError
try:
cli = create_cli(name="my-tool", commands_dir=..., strict=True)
except ClickworkDiscoveryError as e:
for failure in e.failures:
print(f"{failure.category}: {failure.message} ({failure.cause_path})")
raise
Want this in production CIs where a silently-dropped command is worse than a loud crash? Turn it on. Leave it off for dev-mode REPLs where you want to keep iterating even with one broken command file.
py.typed marker¶
clickwork is now a PEP 561 typed
package. Downstream mypy / pyright / pyre users automatically pick up
clickwork's inline annotations. No action required; if you previously
had a mypy.ini entry to ignore-missing-imports for clickwork, you
can remove it.
clickwork._deprecated (internal, but flagged)¶
clickwork 1.0 introduces an internal @deprecated(since, removed_in,
reason) decorator at clickwork._deprecated.deprecated. It is
underscore-prefixed and NOT re-exported; plugin authors should not
import from it. The reason it matters to you: starting in 1.0, any
symbol we deprecate will emit a DeprecationWarning the first time
you call it, with a clickwork: prefix you can filter on. The 0.x
pattern of "a future release silently changes behavior" is no longer
how we evolve the public API. See
API_POLICY.md for the runway
guarantee (one full minor release of overlap before removal).
To filter clickwork deprecation warnings in your own test runs:
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = ["ignore:clickwork\\::DeprecationWarning"]
Cross-references¶
- CHANGELOG.md -- full per-release change ledger.
- API_POLICY.md -- the post-1.0 compatibility promise, Click and Python version-range policy, deprecation runway length.
- LLM_REFERENCE.md "Common Footguns" section --
every-day mistakes (patching prereqs,
ClickExceptionrouting,CliRunnerstreams, URL-encoding, secrets-in-argv, and more). - GUIDE.md -- full tutorial for a clean-slate setup, kept up to date with the 1.0 API.