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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project are documented here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): only a major version changes the
public API, a minor version adds to it, and a patch version fixes it.

## [Unreleased]

### Added

- `feature_flags.targeting_context()`, `feature_flags.lint_rule()` and `feature_flags.test_rule()`:
the facts a targeting rule can read, the check the API runs before accepting one, and a dry run
of a rule against a customer or an instance.

## [1.0.0] - 2026-10-01

The first release of the Kaiten Python SDK, generated and tested against the contract of
Expand Down
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ client.instances.report_usage("acme-production", "seats", 1)
- **Webhooks included.** Svix signatures verified, all 55 event types parsed into models.
- **Flags through OpenFeature.** Kaiten speaks OFREP, so flags are defined here and
evaluated with the [OpenFeature SDK](#feature-flags) — a vendor-neutral API, not ours.
- **Contract-tested.** All 108 operations the SDK wraps are exercised against the contract, and
- **Contract-tested.** All 111 operations the SDK wraps are exercised against the contract, and
every request body is validated against the API's own schema.

## Contents
Expand Down Expand Up @@ -302,6 +302,27 @@ client.feature_flags.create(
)
```

A rule that names a fact or an entitlement that does not exist does not fail: it never matches.
Check one before it ships — the same check the API runs on create and update — and run it
against a real customer or instance to see the facts it reads:

```python
client.feature_flags.targeting_context() # every fact a rule can read under __kaiten

verdict = client.feature_flags.lint_rule("__kaiten.license.familySlug == 'growth'")
if not verdict.valid:
print(verdict.issues[0].message, verdict.issues[0].line, verdict.issues[0].column)

trial = client.feature_flags.test_rule(
"__kaiten.entitlements['seats'].percentage >= 0.8",
targeting_key="acme",
context={"kaiten": {"instanceSlug": "acme-production"}},
)
print(trial.matched, trial.facts)
```

Neither writes, meters nor audits anything.

**This SDK does not evaluate flags, on purpose.** Kaiten implements
[OFREP](https://openfeature.dev/specification/appendix-c), the OpenFeature Remote Evaluation
Protocol, so evaluation belongs to [OpenFeature](https://openfeature.dev) itself: install the
Expand Down Expand Up @@ -574,10 +595,9 @@ logging.getLogger("kaitencloud").setLevel(logging.INFO)

## API reference

The Core API clients cover 98 of its 107 operations. Of the nine left out, three are
rule-editor tooling for the Kaiten console, four are a signed-in person's own notification feed
and preferences — which an API token has no equivalent of — and two are the OFREP evaluation
endpoints, which an [OpenFeature provider](#feature-flags) calls. The Platform API clients cover
The Core API clients cover 101 of its 107 operations. Of the six left out, four are a signed-in
person's own notification feed and preferences — which an API token has no equivalent of — and
two are the OFREP evaluation endpoints, which an [OpenFeature provider](#feature-flags) calls. The Platform API clients cover
all 10 of its operations. The async clients have the same methods.

<details>
Expand Down Expand Up @@ -648,6 +668,9 @@ all 10 of its operations. The async clients have the same methods.
| `client.feature_flags.update()` | `PUT /feature-flags/{featureFlagSlug}` |
| `client.feature_flags.delete()` | `DELETE /feature-flags/{featureFlagSlug}` |
| `client.feature_flags.manifest()` | `GET /openfeature/v0/manifest` |
| `client.feature_flags.targeting_context()` | `GET /feature-flags/targeting/context` |
| `client.feature_flags.lint_rule()` | `POST /feature-flags/targeting/lint` |
| `client.feature_flags.test_rule()` | `POST /feature-flags/targeting/test` |

</details>

Expand Down
10 changes: 4 additions & 6 deletions openapi/coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ core:
update-feature-flag: feature_flags.update
delete-feature-flag: feature_flags.delete
get-openfeature-manifest: feature_flags.manifest
# targeting rule authoring: what a rule can read, and whether it lints and matches
get-targeting-context: feature_flags.targeting_context
lint-targeting-rule: feature_flags.lint_rule
test-targeting-rule: feature_flags.test_rule

# instances
getInstances: instances.list
Expand Down Expand Up @@ -138,12 +142,6 @@ core:
delete-service-account-token: service_accounts.delete_token

excluded:
get-targeting-context: >-
Rule-editor tooling for the Kaiten console, with no SDK audience. kaiten's own
operation-coverage test excludes the same three operations for the same reason.
lint-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience.
test-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience.

listNotifications: >-
A person's own notification feed. It addresses the signed-in user's rows -- an unread
count and a set of choices about what to be told -- and a machine client holding an API
Expand Down
2 changes: 2 additions & 0 deletions scripts/generate_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
("License", "type"): "LicenseType",
("LicenseEntitlement", "entitlementType"): "LicenseEntitlementType",
("MetadataField", "resourceType"): "MetadataResourceType",
("TargetingContextNode", "type"): "TargetingFactType",
}

# Attribute names that would shadow a pydantic.BaseModel member, keyed by (schema, property).
Expand All @@ -119,6 +120,7 @@
"MetadataResourceType": "The resource a metadata field applies to.",
"ResetAnchor": "What a periodic usage window is aligned on.",
"ResetPeriod": "How often a periodic entitlement's usage resets.",
"TargetingFactType": "The shape of a fact a targeting rule can read.",
"Scope": (
"A permission an organization credential can carry, ``read:<resource>`` or "
"``write:<resource>``; ``write`` implies ``read`` on the same resource."
Expand Down
60 changes: 60 additions & 0 deletions src/kaitencloud/_async/resources/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
FeatureFlagType,
ManifestEnvelope,
ManifestFlag,
TargetingContext,
TargetingParam,
TargetingRuleRehearsal,
TargetingRuleVerdict,
VariantParam,
)
from ._base import AsyncResource
Expand Down Expand Up @@ -119,6 +122,63 @@ async def update(
)
await self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body)

async def targeting_context(self) -> TargetingContext:
"""Return what a targeting rule can read: the facts Kaiten adds under ``__kaiten``.

Each node names a fact as a rule writes it, its shape, and for a map the keys it has
now -- the organization's entitlement slugs, its instances' metadata keys. A rule may
also read any attribute the application puts in its evaluation context.
"""
return await self._api.get("/feature-flags/targeting/context", TargetingContext)

async def lint_rule(self, rule: str) -> TargetingRuleVerdict:
"""Check a CEL targeting rule the way create and update do, without writing anything.

A rule that names a fact or an entitlement that does not exist never matches rather
than failing, so this is how to catch it before it ships. A refused rule is a verdict
with ``valid=False`` and its positioned issues, not an error.
"""
return await self._api.send(
"POST",
"/feature-flags/targeting/lint",
TargetingRuleVerdict,
body={"rule": rule},
idempotent=True,
)

async def test_rule(
self,
rule: str,
*,
targeting_key: str | None = None,
context: Mapping[str, Any] | None = None,
) -> TargetingRuleRehearsal:
"""Run a targeting rule against one evaluation context, as an evaluation would.

Nothing is written, metered or audited. The context is enriched from the same sources
an evaluation uses, so the answer carries the facts the rule actually read alongside
whether it matched -- usually the answer to why it did not.

Args:
rule: The CEL expression.
targeting_key: Who is evaluating; a customer slug gets that customer's license and
entitlements.
context: The rest of the evaluation context, e.g.
``{"kaiten": {"instanceSlug": "acme-production"}}`` for an instance's facts.
"""
body: dict[str, Any] = {"rule": rule}
if targeting_key is not None:
body["targetingKey"] = targeting_key
if context is not None:
body["context"] = dict(context)
return await self._api.send(
"POST",
"/feature-flags/targeting/test",
TargetingRuleRehearsal,
body=body,
idempotent=True,
)

async def delete(self, feature_flag_slug: str) -> None:
"""Delete the feature flag identified by ``feature_flag_slug``."""
await self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug))
Expand Down
60 changes: 60 additions & 0 deletions src/kaitencloud/_sync/resources/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
FeatureFlagType,
ManifestEnvelope,
ManifestFlag,
TargetingContext,
TargetingParam,
TargetingRuleRehearsal,
TargetingRuleVerdict,
VariantParam,
)
from ._base import Resource
Expand Down Expand Up @@ -121,6 +124,63 @@ def update(
)
self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body)

def targeting_context(self) -> TargetingContext:
"""Return what a targeting rule can read: the facts Kaiten adds under ``__kaiten``.

Each node names a fact as a rule writes it, its shape, and for a map the keys it has
now -- the organization's entitlement slugs, its instances' metadata keys. A rule may
also read any attribute the application puts in its evaluation context.
"""
return self._api.get("/feature-flags/targeting/context", TargetingContext)

def lint_rule(self, rule: str) -> TargetingRuleVerdict:
"""Check a CEL targeting rule the way create and update do, without writing anything.

A rule that names a fact or an entitlement that does not exist never matches rather
than failing, so this is how to catch it before it ships. A refused rule is a verdict
with ``valid=False`` and its positioned issues, not an error.
"""
return self._api.send(
"POST",
"/feature-flags/targeting/lint",
TargetingRuleVerdict,
body={"rule": rule},
idempotent=True,
)

def test_rule(
self,
rule: str,
*,
targeting_key: str | None = None,
context: Mapping[str, Any] | None = None,
) -> TargetingRuleRehearsal:
"""Run a targeting rule against one evaluation context, as an evaluation would.

Nothing is written, metered or audited. The context is enriched from the same sources
an evaluation uses, so the answer carries the facts the rule actually read alongside
whether it matched -- usually the answer to why it did not.

Args:
rule: The CEL expression.
targeting_key: Who is evaluating; a customer slug gets that customer's license and
entitlements.
context: The rest of the evaluation context, e.g.
``{"kaiten": {"instanceSlug": "acme-production"}}`` for an instance's facts.
"""
body: dict[str, Any] = {"rule": rule}
if targeting_key is not None:
body["targetingKey"] = targeting_key
if context is not None:
body["context"] = dict(context)
return self._api.send(
"POST",
"/feature-flags/targeting/test",
TargetingRuleRehearsal,
body=body,
idempotent=True,
)

def delete(self, feature_flag_slug: str) -> None:
"""Delete the feature flag identified by ``feature_flag_slug``."""
self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug))
Expand Down
12 changes: 12 additions & 0 deletions src/kaitencloud/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@
ServiceAccount,
SystemTokenIssuance,
Targeting,
TargetingContext,
TargetingContextNode,
TargetingFactType,
TargetingRuleIssue,
TargetingRuleRehearsal,
TargetingRuleVerdict,
Targetings,
Token,
UsageBehavior,
Expand Down Expand Up @@ -186,7 +192,13 @@
"ServiceAccount",
"SystemTokenIssuance",
"Targeting",
"TargetingContext",
"TargetingContextNode",
"TargetingFactType",
"TargetingParam",
"TargetingRuleIssue",
"TargetingRuleRehearsal",
"TargetingRuleVerdict",
"Targetings",
"Token",
"UsageBehavior",
Expand Down
Loading
Loading