Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -636,7 +638,7 @@ all 10 of its operations. The async clients have the same methods.
</details>

<details>
<summary><b>client.feature_flags</b> · <b>client.flags</b></summary>
<summary><b>client.feature_flags</b></summary>

| Method | Operation |
| --- | --- |
Expand Down
4 changes: 2 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions examples/playbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion src/kaitencloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down
8 changes: 5 additions & 3 deletions src/kaitencloud/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 7 additions & 3 deletions src/kaitencloud/_async/resources/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 [])

Expand Down
8 changes: 5 additions & 3 deletions src/kaitencloud/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 7 additions & 3 deletions src/kaitencloud/_sync/resources/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 [])

Expand Down
2 changes: 1 addition & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"),
],
Expand Down
55 changes: 52 additions & 3 deletions tests/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"<b>(client|platform)\.(\w+)</b>", 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()
Expand Down
Loading