diff --git a/README.md b/README.md index 6cae9d8..423071d 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) @@ -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 @@ -481,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 @@ -497,15 +498,17 @@ 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 └── 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. @@ -527,7 +530,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 +564,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 +638,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 c05bc95..1226b39 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 ``` @@ -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 9d21310..20cbc0f 100644 --- a/examples/playbook.py +++ b/examples/playbook.py @@ -1006,11 +1006,11 @@ 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") - 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/__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 bd805bc..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) @@ -124,7 +126,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 +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 = AsyncAPIClient( base_url=normalize_base_url(url), diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py index e6efab5..44fdaf0 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]: @@ -123,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/client.py b/src/kaitencloud/_sync/client.py index 994af67..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) @@ -126,7 +128,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 +197,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/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py index dd4c2e1..d0f2413 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]: @@ -125,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 []) 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/"), ], 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()