From 6f4907cbe23fa760a0e98df960edcf0aed5e58dc Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:51:59 +0200 Subject: [PATCH 1/6] fix: name port 6060 in the base URL hint and examples A client built with no address raises "No Kaiten API address: pass base_url=... (your deployment's, e.g. http://localhost:6000)". Nothing answers on 6000: a Community Edition API listens on 6060, under http://localhost:6060/api. The same address was in the client's docstring and in the playbook. - src/kaitencloud/_async/client.py: the ValueError hint and the base_url docstring name http://localhost:6060; _sync/client.py is regenerated. /api is still appended when missing, so the hint resolves to http://localhost:6060/api/. - examples/README.md, examples/playbook.py: the KAITEN_BASE_URL export and the --base-url help. - tests/test_client.py: the base URL case with a port uses the same one. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- examples/README.md | 2 +- examples/playbook.py | 2 +- src/kaitencloud/_async/client.py | 4 ++-- src/kaitencloud/_sync/client.py | 4 ++-- tests/test_client.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index c05bc95..18cd5d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,7 +16,7 @@ documentation that runs, and a failure points at the one call that failed. Run it from the repository root, with a credential that may write: ```bash -export KAITEN_BASE_URL=http://localhost:6000 +export KAITEN_BASE_URL=http://localhost:6060 export KAITEN_AUTH_TOKEN=ksh_... # an organization token ``` diff --git a/examples/playbook.py b/examples/playbook.py index 9d21310..8d5add9 100644 --- a/examples/playbook.py +++ b/examples/playbook.py @@ -1006,7 +1006,7 @@ def parse(argv: Sequence[str] | None) -> argparse.Namespace: parser.add_argument("--list", action="store_true", help="list the steps and exit") parser.add_argument("--from", dest="start", metavar="STEP", help="resume at this step") parser.add_argument("--pause", action="store_true", help="wait for Enter before each step") - parser.add_argument("--base-url", help="the API, e.g. http://localhost:6000") + parser.add_argument("--base-url", help="the API, e.g. http://localhost:6060") parser.add_argument("--token", help="an organization token (ksh_...)") parser.add_argument("--platform-org", metavar="UUID", help="mint a token in this organization") parser.add_argument("--state", type=Path, default=STATE_FILE, help="where the run is recorded") diff --git a/src/kaitencloud/_async/client.py b/src/kaitencloud/_async/client.py index bd805bc..7cbc30b 100644 --- a/src/kaitencloud/_async/client.py +++ b/src/kaitencloud/_async/client.py @@ -124,7 +124,7 @@ class AsyncKaitenClient(_AsyncBaseClient): rotated token is picked up. Defaults to the ``KAITEN_AUTH_TOKEN`` environment variable. A platform credential (``ksm_...``) is refused: use :class:`AsyncKaitenPlatformClient`. - base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6000``. + base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6060``. ``/api`` is appended when missing. Defaults to ``KAITEN_BASE_URL``; there is no default beyond it, since every deployment has an address of its own. timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. @@ -193,7 +193,7 @@ def __init__( if not url or not url.strip(): raise ValueError( "No Kaiten API address: pass base_url=... (your deployment's, e.g. " - f"http://localhost:6000) or set {ENV_BASE_URL}." + f"http://localhost:6060) or set {ENV_BASE_URL}." ) self._api = AsyncAPIClient( base_url=normalize_base_url(url), diff --git a/src/kaitencloud/_sync/client.py b/src/kaitencloud/_sync/client.py index 994af67..35286b9 100644 --- a/src/kaitencloud/_sync/client.py +++ b/src/kaitencloud/_sync/client.py @@ -126,7 +126,7 @@ class KaitenClient(_BaseClient): rotated token is picked up. Defaults to the ``KAITEN_AUTH_TOKEN`` environment variable. A platform credential (``ksm_...``) is refused: use :class:`KaitenPlatformClient`. - base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6000``. + base_url: Where your Kaiten deployment's API lives, e.g. ``http://localhost:6060``. ``/api`` is appended when missing. Defaults to ``KAITEN_BASE_URL``; there is no default beyond it, since every deployment has an address of its own. timeout: Seconds, or an :class:`httpx.Timeout`. Defaults to 30 seconds. @@ -195,7 +195,7 @@ def __init__( if not url or not url.strip(): raise ValueError( "No Kaiten API address: pass base_url=... (your deployment's, e.g. " - f"http://localhost:6000) or set {ENV_BASE_URL}." + f"http://localhost:6060) or set {ENV_BASE_URL}." ) self._api = APIClient( base_url=normalize_base_url(url), diff --git a/tests/test_client.py b/tests/test_client.py index f5d3490..9565017 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -31,7 +31,7 @@ ("https://kaiten.example.com/", "https://kaiten.example.com/api/"), ("https://kaiten.example.com/api", "https://kaiten.example.com/api/"), ("https://kaiten.example.com/api/", "https://kaiten.example.com/api/"), - ("http://localhost:6000", "http://localhost:6000/api/"), + ("http://localhost:6060", "http://localhost:6060/api/"), ("https://example.com/kaiten/api", "https://example.com/kaiten/api/"), (" https://example.com/prefix ", "https://example.com/prefix/api/"), ], From 45dae8b3d170257c6d5bd579f9cc5ca244f637cf Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:52:17 +0200 Subject: [PATCH 2/6] docs: give the first example the base_url it needs The README opens with KaitenClient(token="ksh_..."), and the package and client docstrings show the same line. There is no default API address, so it raises ValueError unless KAITEN_BASE_URL happens to be set, which the README only says eighty lines further down. - README.md: the opening example passes base_url, as the Quick start does. - src/kaitencloud/__init__.py, src/kaitencloud/_async/client.py: the two docstring examples do too; _sync/client.py is regenerated. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/kaitencloud/__init__.py | 2 +- src/kaitencloud/_async/client.py | 4 +++- src/kaitencloud/_sync/client.py | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6cae9d8..ccf4ee8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ customers, instances, licenses, entitlements, usage metering, feature flags and ```python from kaitencloud import KaitenClient -client = KaitenClient(token="ksh_...") +client = KaitenClient(base_url="https://kaiten.example.com", token="ksh_...") for customer in client.customers.list(): print(customer.name) diff --git a/src/kaitencloud/__init__.py b/src/kaitencloud/__init__.py index c2db8fe..dd539e5 100644 --- a/src/kaitencloud/__init__.py +++ b/src/kaitencloud/__init__.py @@ -7,7 +7,7 @@ from kaitencloud import KaitenClient - client = KaitenClient(token="ksh_...") + client = KaitenClient(base_url="https://kaiten.example.com", token="ksh_...") for instance in client.instances.list(): print(instance.slug, instance.status) """ diff --git a/src/kaitencloud/_async/client.py b/src/kaitencloud/_async/client.py index 7cbc30b..5b36901 100644 --- a/src/kaitencloud/_async/client.py +++ b/src/kaitencloud/_async/client.py @@ -115,7 +115,9 @@ class AsyncKaitenClient(_AsyncBaseClient): (``ksh_...``) or a JWT from your identity provider. It covers customers, instances, licenses, entitlements and usage, feature flags, releases and connectors:: - async with AsyncKaitenClient(token="ksh_...") as client: + async with AsyncKaitenClient( + base_url="https://kaiten.example.com", token="ksh_..." + ) as client: for customer in await client.customers.list(): print(customer.slug) diff --git a/src/kaitencloud/_sync/client.py b/src/kaitencloud/_sync/client.py index 35286b9..7699942 100644 --- a/src/kaitencloud/_sync/client.py +++ b/src/kaitencloud/_sync/client.py @@ -117,7 +117,9 @@ class KaitenClient(_BaseClient): (``ksh_...``) or a JWT from your identity provider. It covers customers, instances, licenses, entitlements and usage, feature flags, releases and connectors:: - with KaitenClient(token="ksh_...") as client: + with KaitenClient( + base_url="https://kaiten.example.com", token="ksh_..." + ) as client: for customer in client.customers.list(): print(customer.slug) From 22da39090f86c97703bc2c75e4659276ebeefe80 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:53:00 +0200 Subject: [PATCH 3/6] docs: stop describing a flag evaluation the SDK does not have The SDK defines flags and leaves their evaluation to OpenFeature, as the README's own Feature flags section says. Four other places still described an evaluation client: - README.md, Errors: FlagEvaluationError is not an exception of the package. The tree now ends APIStatusError on InternalServerError. - README.md, Retries and timeouts: "flag evaluations" are not among the retried requests, since the SDK sends none. The rest of the row matches the calls marked safe to repeat. - README.md, Logging: the only record the package logs is a retry, at INFO. Nothing is logged at WARNING about a flag falling back to its default; examples/README.md and the playbook's --verbose help said the same. - README.md, API reference, and the FeatureFlags docstring: there is no client.flags. The docstring points at OpenFeature instead; _sync/resources/feature_flags.py is regenerated. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++------- examples/README.md | 2 +- examples/playbook.py | 2 +- src/kaitencloud/_async/resources/feature_flags.py | 5 +++-- src/kaitencloud/_sync/resources/feature_flags.py | 5 +++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ccf4ee8..87864ad 100644 --- a/README.md +++ b/README.md @@ -497,9 +497,8 @@ KaitenError │ │ └── ThresholdExceededError a usage report refused at the cap │ ├── UnprocessableEntityError 422 │ ├── RateLimitError 429 -│ ├── InternalServerError 5xx -│ │ └── ServiceUnavailableError 503 -│ └── FlagEvaluationError a flag could not be evaluated +│ └── InternalServerError 5xx +│ └── ServiceUnavailableError 503 ├── APIResponseValidationError a 2xx response that breaks the contract ├── PaginationError a list the API reported as incomplete ├── WebhookVerificationError a webhook that fails verification @@ -527,7 +526,7 @@ backoff and jitter, honouring `Retry-After` — but only when repeating it is sa | Request | Retried | | --- | --- | | `GET`, `PUT`, `DELETE` | ✓ | -| Flag evaluations, instance status and lifecycle updates, metadata dry runs and reorders, `organizations.ensure()`, `connectors.register()` | ✓ — they converge | +| Instance status and lifecycle updates, metadata dry runs and reorders, `organizations.ensure()`, `connectors.register()` | ✓ — they converge | | Any other `POST` or `PATCH`: creations, usage reports, upserts | only if the connection never opened | | A `401` or `403` | never | @@ -561,8 +560,7 @@ All models, and the typed dictionaries methods accept, are importable from `kait ### Logging -The SDK logs to the `kaitencloud` logger: retries at `INFO`, flag evaluations that fell back to -their default at `WARNING`. Credentials are never logged. +The SDK logs each retry to the `kaitencloud` logger, at `INFO`. Credentials are never logged. ```python import logging @@ -636,7 +634,7 @@ all 10 of its operations. The async clients have the same methods.
-client.feature_flags · client.flags +client.feature_flags | Method | Operation | | --- | --- | diff --git a/examples/README.md b/examples/README.md index 18cd5d1..1226b39 100644 --- a/examples/README.md +++ b/examples/README.md @@ -113,7 +113,7 @@ state file to start fresh. | `--base-url`, `--token` | Instead of `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN` | | `--platform-org UUID` | The organization the `platform` step mints a token in | | `--state PATH` | Where the run is recorded (default `.kaiten-playbook.json`) | -| `--verbose` | Log retries and fallbacks on the `kaitencloud` logger | +| `--verbose` | Log retries on the `kaitencloud` logger | The `platform` step skips itself unless `KAITEN_PLATFORM_BASE_URL` and `KAITEN_PLATFORM_TOKEN` are set: the Platform API has its own listener, reachable only from inside your infrastructure. diff --git a/examples/playbook.py b/examples/playbook.py index 8d5add9..20cbc0f 100644 --- a/examples/playbook.py +++ b/examples/playbook.py @@ -1010,7 +1010,7 @@ def parse(argv: Sequence[str] | None) -> argparse.Namespace: parser.add_argument("--token", help="an organization token (ksh_...)") parser.add_argument("--platform-org", metavar="UUID", help="mint a token in this organization") parser.add_argument("--state", type=Path, default=STATE_FILE, help="where the run is recorded") - parser.add_argument("--verbose", action="store_true", help="log retries and fallbacks") + parser.add_argument("--verbose", action="store_true", help="log retries") return parser.parse_args(argv) diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py index e6efab5..dbe9263 100644 --- a/src/kaitencloud/_async/resources/feature_flags.py +++ b/src/kaitencloud/_async/resources/feature_flags.py @@ -25,8 +25,9 @@ class AsyncFeatureFlags(AsyncResource): """Feature flags: variants, targeting rules and default variants. - This namespace manages flag definitions. To evaluate a flag for a context, use - ``client.flags``. The builders in :mod:`kaitencloud.targeting` write variants and rules. + This namespace manages flag definitions. It does not evaluate them: Kaiten implements + OFREP, so use an OpenFeature SDK with its generic OFREP provider. The builders in + :mod:`kaitencloud.targeting` write variants and rules. """ async def list(self) -> builtins.list[FeatureFlag]: diff --git a/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py index dd4c2e1..af45355 100644 --- a/src/kaitencloud/_sync/resources/feature_flags.py +++ b/src/kaitencloud/_sync/resources/feature_flags.py @@ -27,8 +27,9 @@ class FeatureFlags(Resource): """Feature flags: variants, targeting rules and default variants. - This namespace manages flag definitions. To evaluate a flag for a context, use - ``client.flags``. The builders in :mod:`kaitencloud.targeting` write variants and rules. + This namespace manages flag definitions. It does not evaluate them: Kaiten implements + OFREP, so use an OpenFeature SDK with its generic OFREP provider. The builders in + :mod:`kaitencloud.targeting` write variants and rules. """ def list(self) -> builtins.list[FeatureFlag]: From fd008744e229329227610aa6d10b3bb983fb930a Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:54:14 +0200 Subject: [PATCH 4/6] docs: say which flags the OpenFeature manifest holds The README and the manifest() docstring said the manifest returns every flag. The contract in openapi/openapi.yaml says otherwise: it "returns a manifest of all feature flags that have a fallback_value set in their metadata". A flag without one is absent, and a reader generating typed accessors from the manifest would not learn why. - README.md, Feature flags: the manifest holds each flag whose metadata sets a fallback_value. - src/kaitencloud/_async/resources/feature_flags.py: the same in the docstring; _sync/resources/feature_flags.py is regenerated. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- src/kaitencloud/_async/resources/feature_flags.py | 5 ++++- src/kaitencloud/_sync/resources/feature_flags.py | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 87864ad..68e7f9e 100644 --- a/README.md +++ b/README.md @@ -340,8 +340,9 @@ entitlements that subject holds. Splits are sticky: a subject keeps its variant evaluation to the next. A failed evaluation returns the default you passed rather than raise, which is OpenFeature's contract, not ours. -`client.feature_flags.manifest()` returns the OpenFeature manifest — every flag, its type and -its default — for tooling that generates typed accessors from flag definitions. +`client.feature_flags.manifest()` returns the OpenFeature manifest — each flag whose metadata +sets a `fallback_value`, with its type and its default — for tooling that generates typed +accessors from flag definitions. ### Releases and deployment zones diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py index dbe9263..44fdaf0 100644 --- a/src/kaitencloud/_async/resources/feature_flags.py +++ b/src/kaitencloud/_async/resources/feature_flags.py @@ -124,7 +124,10 @@ async def delete(self, feature_flag_slug: str) -> None: await self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug)) async def manifest(self) -> builtins.list[ManifestFlag]: - """Return the OpenFeature manifest: every flag with its type and default value.""" + """Return the OpenFeature manifest: each flag it lists, with its type and default value. + + The manifest lists the flags whose metadata sets a ``fallback_value``, not every flag. + """ envelope = await self._api.get("/openfeature/v0/manifest", ManifestEnvelope) return builtins.list(envelope.flags or []) diff --git a/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py index af45355..d0f2413 100644 --- a/src/kaitencloud/_sync/resources/feature_flags.py +++ b/src/kaitencloud/_sync/resources/feature_flags.py @@ -126,7 +126,10 @@ def delete(self, feature_flag_slug: str) -> None: self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug)) def manifest(self) -> builtins.list[ManifestFlag]: - """Return the OpenFeature manifest: every flag with its type and default value.""" + """Return the OpenFeature manifest: each flag it lists, with its type and default value. + + The manifest lists the flags whose metadata sets a ``fallback_value``, not every flag. + """ envelope = self._api.get("/openfeature/v0/manifest", ManifestEnvelope) return builtins.list(envelope.flags or []) From 7e3db45110cd292f0233ae15f00a86dd8bcb8bb5 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:54:30 +0200 Subject: [PATCH 5/6] docs: say which errors are not a KaitenError The README said every exception derives from KaitenError. A client built with no base_url raises a plain ValueError, as the first example did until it was given one, and so do a malformed address, a datetime without a timezone and an empty slug; a usage value that is not a number raises TypeError. An `except KaitenError` catches none of them. - README.md, Errors: the tree is of the exceptions the SDK defines, and a sentence under it names what a refused argument raises instead. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 68e7f9e..423071d 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,7 @@ entries = client.instances.list_audit_trails( ### Errors -Every exception derives from `KaitenError`: +Every exception the SDK defines derives from `KaitenError`: ```text KaitenError @@ -506,6 +506,9 @@ KaitenError └── WebhookPayloadError an authentic webhook that breaks its event contract ``` +A bad argument is refused before anything is sent, with a plain `ValueError` or `TypeError`: a +missing `base_url`, a datetime without a timezone, a usage value that is not a number. + An `APIStatusError` keeps everything the API said. Branch on `code` rather than on the status: a 409 for a reached limit and a 409 for a taken slug share a status, not a meaning. From a0ddeb9c34f8f35dce29587d00a202a50839e091 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Tue, 13 Oct 2026 23:55:44 +0200 Subject: [PATCH 6/6] test: hold the README's prose to what the package has tests/test_readme.py parses the README's python blocks and checks the names they import and call. The three defects just corrected sat outside its reach: an example that parses but cannot run without an environment variable, an exception in a text block, a namespace in a . - The examples shown before the README says KAITEN_BASE_URL is assumed must pass base_url and token to the client they build. - Every name in the error tree is an exception the package exports, under KaitenError. The tree need not list them all. - Every namespace and method the API reference names exists on the client. Each of the three fails on the README as released in 1.0.0. Signed-off-by: Alexandre Bergere Co-Authored-By: Claude Opus 5.5 --- tests/test_readme.py | 55 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/tests/test_readme.py b/tests/test_readme.py index c45506d..a0ce926 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -5,9 +5,11 @@ A README is the first code a user copies. These tests keep it from drifting away from the SDK: every ``python`` block must parse, every name it imports from ``kaitencloud`` must exist, and -every ``client.x.y()`` / ``platform.x.y()`` call must name a real method. The links of both -READMEs -- this one and ``examples/README.md``, which it hands the playbook over to -- must -point at a file that exists and a heading that is really there. +every ``client.x.y()`` / ``platform.x.y()`` call must name a real method. What surrounds the +examples is held to the same rule: the error tree, and the API reference's namespaces and +methods, name only what the package has. The links of both READMEs -- this one and +``examples/README.md``, which it hands the playbook over to -- must point at a file that exists +and a heading that is really there. """ from __future__ import annotations @@ -21,6 +23,7 @@ import pytest from helpers import BASE_URL, PLATFORM_URL +import kaitencloud from kaitencloud import KaitenClient, KaitenPlatformClient, targeting, usage ROOT = Path(__file__).resolve().parent.parent @@ -33,6 +36,7 @@ EXAMPLES = re.findall(r"```python\n(.*?)```", README, flags=re.DOTALL) CLIENT_CALL = re.compile(r"\b(client|platform)\.(\w+)(?:\.(\w+))?\(") HELPER_CALL = re.compile(r"\b(targeting|usage)\.(\w+)\(") +ENVIRONMENT_ASSUMED = "With `KAITEN_BASE_URL` and `KAITEN_AUTH_TOKEN` set" @pytest.fixture @@ -81,6 +85,51 @@ def test_every_helper_call_names_a_real_function() -> None: assert callable(getattr(modules[module], function)), f"{module}.{function}" +def test_the_first_examples_name_the_api_address() -> None: + """An example shown before the environment is assumed passes ``base_url``. + + There is no default address, so without it the first line a reader copies raises. + """ + standalone, sentence, _ = README.partition(ENVIRONMENT_ASSUMED) + assert sentence, "the README no longer says where it starts assuming the environment" + built = [ + node + for example in re.findall(r"```python\n(.*?)```", standalone, flags=re.DOTALL) + for node in ast.walk(ast.parse(example)) + if isinstance(node, ast.Call) and getattr(node.func, "id", "").endswith("KaitenClient") + ] + assert built + for call in built: + assert {"base_url", "token"} <= {keyword.arg for keyword in call.keywords} + + +def test_the_error_tree_names_real_exceptions() -> None: + tree = re.search(r"```text\nKaitenError\n(.*?)```", README, flags=re.DOTALL) + assert tree is not None + names = re.findall(r"── (\w+)", tree.group(1)) + assert names + for name in names: + error = getattr(kaitencloud, name, None) + assert isinstance(error, type), name + assert issubclass(error, kaitencloud.KaitenError), name + + +def test_the_api_reference_names_real_namespaces_and_methods(clients: dict[str, object]) -> None: + """The reference is tables rather than ``python`` blocks, so the checks above skip it.""" + reference = README[README.index("\n## API reference") : README.index("\n## How the SDK stays")] + namespaces = re.findall(r"(client|platform)\.(\w+)", reference) + calls = CLIENT_CALL.findall(reference) + assert namespaces + assert calls + for owner, attribute in namespaces: + assert hasattr(clients[owner], attribute), f"{owner}.{attribute}" + for owner, attribute, method in calls: + target = getattr(clients[owner], attribute) + if method: + target = getattr(target, method) + assert callable(target), f"{owner}.{attribute}.{method}" + + def anchors(text: str) -> set[str]: """The fragments GitHub derives from a document's headings.""" found = set()