From d821112582fb8bbf3cef34ce5a64f217165f4944 Mon Sep 17 00:00:00 2001 From: Richard Quaicoe Date: Tue, 24 Mar 2026 01:17:19 +0000 Subject: [PATCH] Feat: added a full fledged feature flag support with openfeature integration --- README.md | 68 +- docs/changelog.md | 2 + docs/index.md | 3 + docs/reference/feature-flags.md | 443 ++++++++ docs/tutorial/feature-flags.md | 455 ++++++++ docs/tutorial/installation.md | 9 +- examples/fastapi/feature_flags.py | 434 ++++++++ examples/fastapi/multi_service.py | 13 +- examples/fastapi/shield_server.py | 2 + mkdocs.yml | 2 + pyproject.toml | 21 +- shield/admin/api.py | 357 +++++++ shield/admin/app.py | 83 ++ shield/cli/client.py | 96 ++ shield/cli/main.py | 962 +++++++++++++++++ shield/core/backends/base.py | 69 ++ shield/core/backends/memory.py | 13 +- shield/core/backends/server.py | 97 +- shield/core/engine.py | 196 ++++ shield/core/feature_flags/__init__.py | 153 +++ shield/core/feature_flags/_context.py | 65 ++ shield/core/feature_flags/_guard.py | 26 + shield/core/feature_flags/client.py | 171 +++ shield/core/feature_flags/evaluator.py | 443 ++++++++ shield/core/feature_flags/hooks.py | 168 +++ shield/core/feature_flags/models.py | 590 +++++++++++ shield/core/feature_flags/provider.py | 199 ++++ shield/core/feature_flags/scheduler.py | 233 +++++ shield/dashboard/routes.py | 850 +++++++++++++++ shield/dashboard/static/shield.min.css | 2 +- shield/dashboard/templates/base.html | 750 +++++++++----- shield/dashboard/templates/flag_detail.html | 773 ++++++++++++++ shield/dashboard/templates/flags.html | 136 +++ shield/dashboard/templates/index.html | 40 +- shield/dashboard/templates/login.html | 32 +- .../templates/partials/flag_eval_result.html | 137 +++ .../templates/partials/flag_row.html | 131 +++ .../templates/partials/flag_rows.html | 34 + .../templates/partials/modal_flag_create.html | 105 ++ .../templates/partials/modal_flag_eval.html | 112 ++ .../partials/modal_segment_create.html | 86 ++ .../partials/modal_segment_detail.html | 118 +++ .../partials/modal_segment_view.html | 151 +++ .../templates/partials/segment_row.html | 96 ++ .../templates/partials/segment_rows.html | 34 + .../partials/segment_rules_section.html | 172 +++ shield/dashboard/templates/segments.html | 112 ++ shield/sdk/__init__.py | 42 + shield/sdk/flag_provider.py | 174 ++++ tests/admin/test_flag_api.py | 651 ++++++++++++ tests/core/feature_flags/__init__.py | 0 tests/core/feature_flags/test_client.py | 270 +++++ tests/core/feature_flags/test_evaluator.py | 524 ++++++++++ tests/core/feature_flags/test_models.py | 424 ++++++++ tests/core/feature_flags/test_provider.py | 529 ++++++++++ tests/core/feature_flags/test_scheduler.py | 348 +++++++ tests/core/feature_flags/test_sync_client.py | 222 ++++ tests/dashboard/test_flag_dashboard.py | 910 ++++++++++++++++ tests/dashboard/test_routes.py | 7 +- tests/fastapi/_helpers.py | 21 + tests/fastapi/test_acceptance.py | 3 +- tests/fastapi/test_deprecated.py | 15 +- tests/fastapi/test_global_maintenance.py | 5 +- tests/fastapi/test_global_maintenance_docs.py | 13 +- tests/fastapi/test_middleware.py | 3 +- tests/fastapi/test_openapi.py | 11 +- tests/fastapi/test_openapi_maintenance.py | 19 +- tests/fastapi/test_parameterized_routes.py | 7 +- tests/fastapi/test_router.py | 3 +- tests/fastapi/test_startup_scan.py | 3 +- tests/sdk/__init__.py | 0 tests/sdk/test_flag_provider.py | 346 +++++++ tests/test_flags_cli.py | 975 ++++++++++++++++++ uv.lock | 40 +- 74 files changed, 14462 insertions(+), 347 deletions(-) create mode 100644 docs/reference/feature-flags.md create mode 100644 docs/tutorial/feature-flags.md create mode 100644 examples/fastapi/feature_flags.py create mode 100644 shield/core/feature_flags/__init__.py create mode 100644 shield/core/feature_flags/_context.py create mode 100644 shield/core/feature_flags/_guard.py create mode 100644 shield/core/feature_flags/client.py create mode 100644 shield/core/feature_flags/evaluator.py create mode 100644 shield/core/feature_flags/hooks.py create mode 100644 shield/core/feature_flags/models.py create mode 100644 shield/core/feature_flags/provider.py create mode 100644 shield/core/feature_flags/scheduler.py create mode 100644 shield/dashboard/templates/flag_detail.html create mode 100644 shield/dashboard/templates/flags.html create mode 100644 shield/dashboard/templates/partials/flag_eval_result.html create mode 100644 shield/dashboard/templates/partials/flag_row.html create mode 100644 shield/dashboard/templates/partials/flag_rows.html create mode 100644 shield/dashboard/templates/partials/modal_flag_create.html create mode 100644 shield/dashboard/templates/partials/modal_flag_eval.html create mode 100644 shield/dashboard/templates/partials/modal_segment_create.html create mode 100644 shield/dashboard/templates/partials/modal_segment_detail.html create mode 100644 shield/dashboard/templates/partials/modal_segment_view.html create mode 100644 shield/dashboard/templates/partials/segment_row.html create mode 100644 shield/dashboard/templates/partials/segment_rows.html create mode 100644 shield/dashboard/templates/partials/segment_rules_section.html create mode 100644 shield/dashboard/templates/segments.html create mode 100644 shield/sdk/flag_provider.py create mode 100644 tests/admin/test_flag_api.py create mode 100644 tests/core/feature_flags/__init__.py create mode 100644 tests/core/feature_flags/test_client.py create mode 100644 tests/core/feature_flags/test_evaluator.py create mode 100644 tests/core/feature_flags/test_models.py create mode 100644 tests/core/feature_flags/test_provider.py create mode 100644 tests/core/feature_flags/test_scheduler.py create mode 100644 tests/core/feature_flags/test_sync_client.py create mode 100644 tests/dashboard/test_flag_dashboard.py create mode 100644 tests/fastapi/_helpers.py create mode 100644 tests/sdk/__init__.py create mode 100644 tests/sdk/test_flag_provider.py create mode 100644 tests/test_flags_cli.py diff --git a/README.md b/README.md index 81b08bd..d9acb41 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
API Shield -

Route(API) lifecycle management for ASGI Python web frameworks — maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.

+

Route(API) lifecycle management for ASGI Python web frameworks — maintenance mode, environment gating, deprecation, rate limiting, feature flags, admin panels, and more. No restarts required.

PyPI Python versions @@ -33,6 +33,7 @@ These features are framework-agnostic and available to any adapter. | ⏰ **Scheduled windows** | `asyncio`-native scheduler — maintenance windows activate and deactivate automatically | | 🔔 **Webhooks** | Fire HTTP POST on every state change — built-in Slack formatter and custom formatters supported | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters — tiered limits, burst allowance, runtime mutation | +| 🚩 **Feature flags** | Boolean, string, integer, float, and JSON flags — targeting rules, user segments, percentage rollouts, prerequisites, and a live evaluation stream. Built on the [OpenFeature](https://openfeature.dev/) standard | | 🏗️ **Shield Server** | Centralised control plane for multi-service architectures — SDK clients sync state via SSE with zero per-request latency | | 🌐 **Multi-service CLI** | `SHIELD_SERVICE` env var scopes every command; `shield services` lists connected services | @@ -193,6 +194,70 @@ Requires `api-shield[rate-limit]`. Powered by [limits](https://limits.readthedoc --- +## Feature flags + +api-shield ships a full feature flag system built on the [OpenFeature](https://openfeature.dev/) standard. All five flag types, multi-condition targeting rules, user segments, percentage rollouts, and a live evaluation stream — managed from the dashboard or CLI with no code changes. + +```python +from shield.core.feature_flags.models import ( + FeatureFlag, FlagType, FlagVariation, RolloutVariation, + TargetingRule, RuleClause, Operator, EvaluationContext, +) + +engine.use_openfeature() + +# Define a boolean flag with a 20% rollout and individual targeting +await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ + RolloutVariation(variation="on", weight=20_000), # 20% + RolloutVariation(variation="off", weight=80_000), # 80% + ], + targets={"on": ["beta_tester_1"]}, # individual targeting + rules=[ + TargetingRule( + description="Enterprise users always get the new flow", + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["enterprise"])], + variation="on", + ) + ], + ) +) + +# Evaluate in an async route handler +ctx = EvaluationContext(key=user_id, attributes={"plan": user.plan}) +enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) + +# Evaluate in a sync def handler (thread-safe) +enabled = engine.sync.flag_client.get_boolean_value("new-checkout", False, {"targeting_key": user_id}) +``` + +Manage flags and segments from the CLI: + +```bash +shield flags list +shield flags eval new-checkout --user user_123 +shield flags disable new-checkout # kill-switch +shield flags enable new-checkout +shield flags stream # live evaluation events + +shield segments create beta_users --name "Beta Users" +shield segments include beta_users --context-key user_123,user_456 +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +``` + +Requires `api-shield[flags]`. + +--- + ## Framework support api-shield is built on the **ASGI** standard. The core (`shield.core`) is completely framework-agnostic and has zero framework imports. Any ASGI framework can be supported — either via a Starlette `BaseHTTPMiddleware` (for Starlette-based frameworks) or a raw ASGI callable for frameworks like Quart and Django that implement the ASGI spec independently. @@ -265,6 +330,7 @@ Full documentation at **[attakay78.github.io/api-shield](https://attakay78.githu | [Tutorial](https://attakay78.github.io/api-shield/tutorial/installation/) | Get started in 5 minutes | | [Decorators reference](https://attakay78.github.io/api-shield/reference/decorators/) | All decorator options | | [Rate limiting](https://attakay78.github.io/api-shield/tutorial/rate-limiting/) | Per-IP, per-user, tiered limits | +| [Feature flags](https://attakay78.github.io/api-shield/tutorial/feature-flags/) | Targeting rules, segments, rollouts, live events | | [ShieldEngine reference](https://attakay78.github.io/api-shield/reference/engine/) | Programmatic control | | [Backends](https://attakay78.github.io/api-shield/tutorial/backends/) | Memory, File, Redis, Shield Server, custom | | [Admin dashboard](https://attakay78.github.io/api-shield/tutorial/admin-dashboard/) | Mounting ShieldAdmin | diff --git a/docs/changelog.md b/docs/changelog.md index 14d7da1..43a6a03 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **Feature flags** (`api-shield[flags]`): a full feature flag system built on the [OpenFeature](https://openfeature.dev/) standard, supporting boolean, string, integer, float, and JSON flag types with multi-condition targeting rules, reusable user segments (explicit included/excluded lists plus attribute-based rules), percentage rollouts, prerequisite flags, individual user targeting, and a live SSE evaluation stream. Flags and segments are manageable from the admin dashboard (`/shield/flags`, `/shield/segments`) and the CLI (`shield flags *`, `shield segments *`) — including a new `shield segments add-rule` command and an "Add Rule" panel in the Edit Segment modal that lets operators add attribute-based targeting rules without touching code or the REST API directly. + - **`SHIELD_SERVICE` env var fallback on all `--service` CLI options**: `shield status`, `shield enable`, `shield disable`, `shield maintenance`, and `shield schedule` all read `SHIELD_SERVICE` automatically — set it once with `export SHIELD_SERVICE=payments-service` and every command scopes itself to that service without repeating `--service`. An explicit `--service` flag always wins. - **`shield current-service` command**: shows the active service context from the `SHIELD_SERVICE` environment variable, or a hint to set it when the variable is absent. - **`shield services` command**: lists all distinct service names registered with the Shield Server, so you can discover which services are connected before switching context. diff --git a/docs/index.md b/docs/index.md index ddea47e..ac16059 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,6 +104,7 @@ These features are framework-agnostic and available to every adapter. | ⏰ **Scheduled windows** | `asyncio`-native scheduler that activates and deactivates maintenance windows automatically | | 🔔 **Webhooks** | Fire HTTP POST on every state change, with a built-in Slack formatter and support for custom formatters | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters with tiered limits, burst allowance, and runtime policy mutation | +| 🚩 **Feature flags** | Boolean, string, integer, float, and JSON flags with targeting rules, user segments, percentage rollouts, prerequisites, and a live evaluation stream — built on the OpenFeature standard | ### Framework adapters @@ -162,7 +163,9 @@ api-shield is an **ASGI-native** library. The core (`shield.core`) is framework- - [**Tutorial: Installation**](tutorial/installation.md): get up and running in seconds - [**Tutorial: First Decorator**](tutorial/first-decorator.md): put your first route in maintenance mode - [**Tutorial: Rate Limiting**](tutorial/rate-limiting.md): per-IP, per-user, tiered limits, and more +- [**Tutorial: Feature Flags**](tutorial/feature-flags.md): targeting rules, segments, rollouts, and live events - [**Reference: Decorators**](reference/decorators.md): full decorator API - [**Reference: Rate Limiting**](reference/rate-limiting.md): `@rate_limit` parameters, models, and CLI commands - [**Reference: ShieldEngine**](reference/engine.md): programmatic control +- [**Reference: Feature Flags**](reference/feature-flags.md): full flag/segment API, models, and CLI commands - [**Reference: CLI**](reference/cli.md): all CLI commands diff --git a/docs/reference/feature-flags.md b/docs/reference/feature-flags.md new file mode 100644 index 0000000..2883852 --- /dev/null +++ b/docs/reference/feature-flags.md @@ -0,0 +1,443 @@ +# Feature Flags Reference + +API reference for the feature flag system. + +!!! note "Optional dependency" + ```bash + uv add "api-shield[flags]" + ``` + +--- + +## Engine methods + +### `engine.use_openfeature()` + +Activate the feature flag subsystem. Call once before any flag evaluation or flag/segment CRUD. + +```python +engine = make_engine() +engine.use_openfeature() +``` + +--- + +### `engine.flag_client` + +OpenFeature-compatible async flag client. Available after `use_openfeature()`. + +```python +value = await engine.flag_client.get_boolean_value(flag_key, default, context) +value = await engine.flag_client.get_string_value(flag_key, default, context) +value = await engine.flag_client.get_integer_value(flag_key, default, context) +value = await engine.flag_client.get_float_value(flag_key, default, context) +value = await engine.flag_client.get_object_value(flag_key, default, context) +``` + +| Parameter | Type | Description | +|---|---|---| +| `flag_key` | `str` | The flag's unique key | +| `default` | `Any` | Returned when the flag is not found or an error occurs | +| `context` | `EvaluationContext` | Per-request context for targeting | + +--- + +### `engine.sync.flag_client` + +Thread-safe synchronous version for `def` (non-async) route handlers. + +```python +enabled = engine.sync.flag_client.get_boolean_value("my-flag", False, ctx) +``` + +Accepts `EvaluationContext` objects or plain dicts (`{"targeting_key": user_id, ...}`). + +--- + +### `await engine.save_flag(flag)` + +Create or replace a flag. + +```python +await engine.save_flag(FeatureFlag(key="my-flag", ...)) +``` + +--- + +### `await engine.get_flag(key)` + +Return the `FeatureFlag` for `key`, or `None` if not found. + +--- + +### `await engine.list_flags()` + +Return all flags as a list. + +--- + +### `await engine.delete_flag(key)` + +Delete a flag. + +--- + +### `await engine.save_segment(segment)` + +Create or replace a segment. + +--- + +### `await engine.get_segment(key)` + +Return the `Segment` for `key`, or `None`. + +--- + +### `await engine.list_segments()` + +Return all segments as a list. + +--- + +### `await engine.delete_segment(key)` + +Delete a segment. + +--- + +## Models + +### `FeatureFlag` + +Definition of a feature flag. + +```python +class FeatureFlag(BaseModel): + key: str + name: str + description: str = "" + type: FlagType + tags: list[str] = [] + + variations: list[FlagVariation] + off_variation: str + fallthrough: str | list[RolloutVariation] + + enabled: bool = True + prerequisites: list[Prerequisite] = [] + targets: dict[str, list[str]] = {} + rules: list[TargetingRule] = [] + scheduled_changes: list[ScheduledChange] = [] + + status: FlagStatus = FlagStatus.ACTIVE + temporary: bool = True + maintainer: str | None = None + created_at: datetime + updated_at: datetime + created_by: str = "system" +``` + +| Field | Description | +|---|---| +| `key` | Unique identifier used in code: `get_boolean_value("my-flag", ...)` | +| `name` | Human-readable display name | +| `type` | `FlagType.BOOLEAN`, `STRING`, `INTEGER`, `FLOAT`, or `JSON` | +| `variations` | All possible values; must contain at least two | +| `off_variation` | Variation served when `enabled=False` | +| `fallthrough` | Default when no rule matches: a variation name (`str`) or a percentage rollout (`list[RolloutVariation]`) | +| `enabled` | Kill-switch. `False` means all requests get `off_variation` | +| `prerequisites` | Flags that must pass before this flag's rules run | +| `targets` | Individual targeting: `{"on": ["user_1", "user_2"]}` | +| `rules` | Targeting rules evaluated top-to-bottom; first match wins | + +--- + +### `FlagType` + +```python +class FlagType(StrEnum): + BOOLEAN = "boolean" + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + JSON = "json" +``` + +--- + +### `FlagVariation` + +One possible value a flag can return. + +```python +class FlagVariation(BaseModel): + name: str # e.g. "on", "off", "control", "variant_a" + value: bool | str | int | float | dict | list + description: str = "" +``` + +--- + +### `RolloutVariation` + +One bucket in a percentage rollout (used in `fallthrough` or `TargetingRule.rollout`). + +```python +class RolloutVariation(BaseModel): + variation: str # references FlagVariation.name + weight: int # share of traffic, out of 100_000 total +``` + +Weights in a rollout list must sum to `100_000`. Examples: + +| Percentage | Weight | +|---|---| +| 10% | `10_000` | +| 25% | `25_000` | +| 33.33% | `33_333` | +| 50% | `50_000` | + +--- + +### `TargetingRule` + +A rule that matches clauses and serves a variation. + +```python +class TargetingRule(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = [] + variation: str | None = None # mutually exclusive with rollout + rollout: list[RolloutVariation] | None = None + track_events: bool = False +``` + +Clauses within a rule are AND-ed. Rules are evaluated top-to-bottom; first match wins. + +--- + +### `RuleClause` + +A single condition within a targeting rule. + +```python +class RuleClause(BaseModel): + attribute: str # context attribute to inspect, e.g. "plan", "country", "email" + operator: Operator # comparison to apply + values: list[Any] # one or more values; multiple values use OR logic + negate: bool = False # invert the result +``` + +--- + +### `Operator` + +All supported targeting operators. + +```python +class Operator(StrEnum): + # Equality + IS = "is" + IS_NOT = "is_not" + # String + CONTAINS = "contains" + NOT_CONTAINS = "not_contains" + STARTS_WITH = "starts_with" + ENDS_WITH = "ends_with" + MATCHES = "matches" # Python regex + NOT_MATCHES = "not_matches" + # Numeric + GT = "gt" + GTE = "gte" + LT = "lt" + LTE = "lte" + # Date (ISO-8601 lexicographic) + BEFORE = "before" + AFTER = "after" + # Collection + IN = "in" + NOT_IN = "not_in" + # Segment + IN_SEGMENT = "in_segment" + NOT_IN_SEGMENT = "not_in_segment" + # Semantic version (requires `packaging`) + SEMVER_EQ = "semver_eq" + SEMVER_LT = "semver_lt" + SEMVER_GT = "semver_gt" +``` + +--- + +### `Prerequisite` + +A flag that must evaluate to a specific variation before the dependent flag runs. + +```python +class Prerequisite(BaseModel): + flag_key: str # key of the prerequisite flag + variation: str # variation the prerequisite must return +``` + +If the prerequisite returns any other variation, the dependent flag serves `off_variation` with reason `PREREQUISITE_FAIL`. + +--- + +### `Segment` + +A reusable group of users for flag targeting. + +```python +class Segment(BaseModel): + key: str + name: str + description: str = "" + included: list[str] = [] # context keys always in the segment + excluded: list[str] = [] # context keys always excluded (overrides rules + included) + rules: list[SegmentRule] = [] + tags: list[str] = [] + created_at: datetime + updated_at: datetime +``` + +**Evaluation order for context key `k`:** + +1. `k` in `excluded` → not in segment +2. `k` in `included` → in segment +3. Any `SegmentRule` matches → in segment +4. Otherwise → not in segment + +--- + +### `SegmentRule` + +An attribute-based rule inside a segment. Multiple segment rules are OR-ed: if any rule matches, the user is in the segment. + +```python +class SegmentRule(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = [] # all must match (AND logic) +``` + +--- + +### `EvaluationContext` + +Per-request context for flag targeting and rollout bucketing. + +```python +class EvaluationContext(BaseModel): + key: str # required — user/session/org ID + kind: str = "user" # context kind + email: str | None = None + ip: str | None = None + country: str | None = None + app_version: str | None = None + attributes: dict[str, Any] = {} # any additional attributes +``` + +Named fields (`email`, `ip`, `country`, `app_version`) are accessible in rule clauses by the same names. Items in `attributes` are merged in and accessible by key. + +--- + +### `ResolutionDetails` + +Full result of a flag evaluation, surfaced in hooks. + +```python +class ResolutionDetails(BaseModel): + value: Any + variation: str | None = None + reason: EvaluationReason + rule_id: str | None = None # set when reason == RULE_MATCH + prerequisite_key: str | None = None # set when reason == PREREQUISITE_FAIL + error_message: str | None = None # set when reason == ERROR +``` + +--- + +### `EvaluationReason` + +Why a specific value was returned. + +| Value | Description | +|---|---| +| `OFF` | Flag is globally disabled. `off_variation` was served. | +| `TARGET_MATCH` | Context key was in the individual targets list. | +| `RULE_MATCH` | A targeting rule matched. `rule_id` is set. | +| `FALLTHROUGH` | No targeting rule matched. Default rule was served. | +| `PREREQUISITE_FAIL` | A prerequisite flag did not return the required variation. | +| `ERROR` | Provider or evaluation error. Default value was returned. | +| `DEFAULT` | Flag not found. SDK default was returned. | + +--- + +--- + +## REST API + +When `ShieldAdmin` is mounted with `engine.use_openfeature()`, these endpoints are registered under the admin path (e.g. `/shield/api/`): + +### Flags + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/flags` | List all flags | +| `POST` | `/api/flags` | Create a flag (full `FeatureFlag` body) | +| `GET` | `/api/flags/{key}` | Get a single flag | +| `PUT` | `/api/flags/{key}` | Replace a flag (full update) | +| `PATCH` | `/api/flags/{key}` | Partial update | +| `DELETE` | `/api/flags/{key}` | Delete a flag | +| `POST` | `/api/flags/{key}/enable` | Enable (kill-switch off) | +| `POST` | `/api/flags/{key}/disable` | Disable (kill-switch on) | +| `POST` | `/api/flags/{key}/evaluate` | Evaluate for a given context | + +### Segments + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/segments` | List all segments | +| `POST` | `/api/segments` | Create a segment | +| `GET` | `/api/segments/{key}` | Get a single segment | +| `PUT` | `/api/segments/{key}` | Replace a segment | +| `DELETE` | `/api/segments/{key}` | Delete a segment | + +--- + +## Evaluation algorithm + +The evaluator (`FlagEvaluator`) is pure Python with no I/O, unit-testable in isolation. + +```python +from shield.core.feature_flags.evaluator import FlagEvaluator + +evaluator = FlagEvaluator(segments={"beta": beta_segment}) +result = evaluator.evaluate(flag, ctx, all_flags) +print(result.value, result.reason) +``` + +**Rollout bucketing** uses SHA-1 of `"{flag_key}:{ctx.kind}:{ctx.key}"` modulo `100_000`. The same context always lands in the same bucket; bucketing is stable across restarts and deploys. + +**Prerequisite recursion** is limited to depth 10. Circular dependencies are rejected at write time by `engine.save_flag()`. + +--- + +## Dashboard routes + +| URL | Page | +|---|---| +| `/shield/flags` | Flag list with search and status filters | +| `/shield/flags/{key}` | Flag detail (4 tabs: Overview, Targeting, Variations, Settings) | +| `/shield/segments` | Segment list | + +--- + +## Example + +Example: [`examples/fastapi/feature_flags.py`](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/feature_flags.py) + +```bash +uv run uvicorn examples.fastapi.feature_flags:app --reload +``` diff --git a/docs/tutorial/feature-flags.md b/docs/tutorial/feature-flags.md new file mode 100644 index 0000000..d1e58a6 --- /dev/null +++ b/docs/tutorial/feature-flags.md @@ -0,0 +1,455 @@ +# Feature Flags + +Feature flags (also called feature toggles) let you change your application's behavior per user without redeploying. The system is built on the [OpenFeature](https://openfeature.dev/) standard and supports boolean, string, integer, float, and JSON flags, multi-condition targeting rules, user segments, percentage rollouts, and prerequisites. + +!!! note "Optional dependency" + Feature flags require the `flags` extra: + ```bash + uv add "api-shield[flags]" + # or: pip install "api-shield[flags]" + ``` + +--- + +## Overview + +A feature flag has: + +- **Variations**: the possible values it can return (`on`/`off`, `"dark"`/`"light"`, `10`/`50`, etc.) +- **Targeting**: rules that decide which variation a specific user receives +- **Fallthrough**: the default variation when no rule matches (a fixed value or a percentage rollout) +- **Kill-switch**: `enabled=False` skips all rules and returns the `off_variation` immediately + +Evaluation always follows this order: + +``` +1. Flag disabled? → off_variation +2. Prerequisite flags? → off_variation if any prereq fails +3. Individual targets? → fixed variation for specific user keys +4. Targeting rules? → first matching rule wins +5. Fallthrough → fixed variation or percentage bucket +``` + +--- + +## Installation and setup + +```bash +uv add "api-shield[flags]" +``` + +Call `engine.use_openfeature()` once before your first evaluation, then access the flag client through `engine.flag_client`: + +```python +from shield.core.config import make_engine + +engine = make_engine() +engine.use_openfeature() # activates the feature flag subsystem +``` + +The flag client is a standard OpenFeature client — any OpenFeature-aware code works with it directly. + +--- + +## Your first flag + +```python +from shield.core.feature_flags.models import ( + FeatureFlag, FlagType, FlagVariation, RolloutVariation, + EvaluationContext, +) + +# 1. Define and save the flag +await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ # 20% of users get "on" + RolloutVariation(variation="on", weight=20_000), + RolloutVariation(variation="off", weight=80_000), + ], + ) +) + +# 2. Evaluate it in a route handler +ctx = EvaluationContext(key=user_id) +enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) +``` + +Rollout weights are integers out of `100_000`. The above gives exactly 20% to `"on"` and 80% to `"off"`. Bucketing is deterministic: the same `user_id` always lands in the same bucket. + +--- + +## Flag types + +| Type | Method | Python type | +|---|---|---| +| `FlagType.BOOLEAN` | `get_boolean_value` | `bool` | +| `FlagType.STRING` | `get_string_value` | `str` | +| `FlagType.INTEGER` | `get_integer_value` | `int` | +| `FlagType.FLOAT` | `get_float_value` | `float` | +| `FlagType.JSON` | `get_object_value` | `dict` / `list` | + +All evaluation methods share the same signature: `(flag_key, default_value, context)`. + +```python +# String flag +theme = await engine.flag_client.get_string_value("ui-theme", "light", ctx) + +# Integer flag +page_size = await engine.flag_client.get_integer_value("page-size", 10, ctx) + +# Float flag +discount = await engine.flag_client.get_float_value("discount-rate", 0.0, ctx) + +# JSON flag — returns a dict +config = await engine.flag_client.get_object_value("feature-config", {}, ctx) +``` + +--- + +## Evaluation context + +`EvaluationContext` identifies who is making the request. The `key` field is required; use a stable user or session identifier. Everything else is optional: + +```python +ctx = EvaluationContext( + key=user.id, # required — used for individual targeting + rollout bucketing + kind="user", # optional — defaults to "user" + email=user.email, # accessible in rules as the "email" attribute + ip=request.client.host, + country=user.country, + app_version="2.3.1", + attributes={ # any extra attributes your rules need + "plan": user.plan, + "role": user.role, + }, +) +``` + +Named fields (`email`, `ip`, `country`, `app_version`) are accessible in targeting rules by the same names. Custom attributes go in `attributes`. + +--- + +## Targeting rules + +Targeting rules serve a specific variation to users who match certain conditions. + +### Attribute-based rule + +```python +from shield.core.feature_flags.models import TargetingRule, RuleClause, Operator + +FeatureFlag( + key="ui-theme", + ... + rules=[ + TargetingRule( + description="Corporate users → dark theme", + clauses=[ + RuleClause( + attribute="email", + operator=Operator.ENDS_WITH, + values=["@acme.com"], + ) + ], + variation="dark", + ) + ], +) +``` + +### Multiple clauses (AND logic) + +All clauses within a rule must match (AND). Multiple values within one clause are OR-ed. + +```python +TargetingRule( + description="GB Pro users → full discount", + clauses=[ + RuleClause(attribute="country", operator=Operator.IS, values=["GB"]), + RuleClause(attribute="plan", operator=Operator.IN, values=["pro", "enterprise"]), + ], + variation="full", +) +``` + +### Negation + +Flip the result of any clause with `negate=True`: + +```python +RuleClause(attribute="plan", operator=Operator.IS, values=["free"], negate=True) +# matches any user NOT on the free plan +``` + +### Available operators + +| Category | Operators | +|---|---| +| Equality | `IS`, `IS_NOT` | +| String | `CONTAINS`, `NOT_CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `MATCHES`, `NOT_MATCHES` | +| Numeric | `GT`, `GTE`, `LT`, `LTE` | +| Date | `BEFORE`, `AFTER` (ISO-8601 string comparison) | +| Collection | `IN`, `NOT_IN` | +| Segment | `IN_SEGMENT`, `NOT_IN_SEGMENT` | +| Semver | `SEMVER_EQ`, `SEMVER_LT`, `SEMVER_GT` | + +--- + +## Individual targeting + +Override rules for specific users by listing their context keys in `targets`. Individual targets are evaluated after prerequisites but before rules, and always win. + +```python +FeatureFlag( + key="new-checkout", + ... + targets={ + "on": ["beta_tester_1", "beta_tester_2"], # these users always get "on" + "off": ["opted_out_user"], # this user always gets "off" + }, +) +``` + +--- + +## Segments + +A segment is a named, reusable group of users. Define it once and reference it in any flag's targeting rules with `Operator.IN_SEGMENT`. + +### Creating a segment + +```python +from shield.core.feature_flags.models import Segment, SegmentRule, RuleClause, Operator + +# Explicit include list +await engine.save_segment(Segment( + key="beta-users", + name="Beta Users", + included=["user_123", "user_456", "user_789"], +)) + +# Attribute-based rules (any matching rule → user is in the segment) +await engine.save_segment(Segment( + key="enterprise-plan", + name="Enterprise Plan", + rules=[ + SegmentRule(clauses=[ + RuleClause(attribute="plan", operator=Operator.IS, values=["enterprise"]), + ]), + ], +)) + +# Exclude specific users even if they match a rule +await engine.save_segment(Segment( + key="paid-users", + name="Paid Users", + rules=[ + SegmentRule(clauses=[ + RuleClause(attribute="plan", operator=Operator.IN, values=["pro", "enterprise"]), + ]), + ], + excluded=["test_account", "demo_user"], # always excluded, overrides rules +)) +``` + +### Segment evaluation order + +For a given context key `k`: + +1. `k` in `excluded` → **not** in segment +2. `k` in `included` → in segment +3. Any `rules` entry matches → in segment +4. Otherwise → not in segment + +!!! important "Segment key ≠ user key" + The segment **key** (e.g. `"beta-users"`) is the segment's identifier. To make a user with `user_id="alice"` part of this segment, add `"alice"` to `included` — or add a segment rule that matches her attributes. Simply naming the segment `"alice"` does not put her in it. + +### Using a segment in a flag rule + +```python +TargetingRule( + description="Beta users get the new flow", + clauses=[ + RuleClause( + attribute="key", # evaluates ctx.key against the segment + operator=Operator.IN_SEGMENT, + values=["beta-users"], # segment key to reference + ) + ], + variation="on", +) +``` + +### Managing segments from the dashboard + +Open the **Segments** page (`/shield/segments`) and click a segment key or **Edit** to: + +- Add or remove users from the **Included** and **Excluded** lists +- Add **targeting rules** — attribute-based conditions evaluated when a user isn't in the explicit lists + +### Managing segments from the CLI + +```bash +# List all segments +shield segments list + +# Inspect a segment +shield segments get beta-users + +# Create a segment +shield segments create beta_users --name "Beta Users" + +# Add users to the included list +shield segments include beta_users --context-key user_123,user_456 + +# Remove users via the excluded list +shield segments exclude beta_users --context-key opted_out_user + +# Add an attribute-based targeting rule +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +shield segments add-rule beta_users --attribute country --operator is --values GB --description "UK users" + +# Remove a rule (use 'shield segments get' to find rule IDs) +shield segments remove-rule beta_users --rule-id + +# Delete a segment +shield segments delete beta_users +``` + +--- + +## Prerequisites + +Prerequisites let a flag depend on another flag. The dependent flag only proceeds to its rules if the prerequisite flag evaluates to a specific variation. + +```python +from shield.core.feature_flags.models import Prerequisite + +FeatureFlag( + key="advanced-dashboard", + ... + prerequisites=[ + Prerequisite(flag_key="auth-v2", variation="enabled"), + # advanced-dashboard only evaluates if auth-v2 → "enabled" + ], +) +``` + +Prerequisites are recursive up to a depth of 10. Circular dependencies are prevented at write time. + +--- + +## Sync evaluation (plain `def` handlers) + +FastAPI runs plain `def` route handlers in a thread pool. Use `engine.sync.flag_client` for thread-safe synchronous evaluation without any event loop bridging: + +```python +@router.get("/dashboard") +def dashboard(request: Request, user_id: str = "anonymous"): + enabled = engine.sync.flag_client.get_boolean_value( + "new-dashboard", False, {"targeting_key": user_id} + ) + return {"new_dashboard": enabled} +``` + +--- + +## Admin dashboard + +### Flags page (`/shield/flags`) + +Lists all flags with key, type, status, variations, and fallthrough. Use the search box and type/status filters to narrow the list. Click a flag key to open the detail page. + +### Flag detail page + +| Tab | Contents | +|---|---| +| **Overview** | Key metrics: evaluation count, rule match rate, fallthrough rate, top variations | +| **Targeting** | Add / remove prerequisite flags; manage individual targets; add / edit / delete targeting rules | +| **Variations** | Add, rename, or remove variations; change the fallthrough and off-variation | +| **Settings** | Edit name, description, tags, maintainer, temporary flag flag, and scheduled changes | + +### Segments page (`/shield/segments`) + +Lists all segments with included/excluded/rules counts. Click a segment to open its detail modal, or use the **Edit** button to manage included, excluded, and targeting rules. + +--- + +## CLI reference + +### `shield flags` + +```bash +shield flags list # all flags +shield flags get new-checkout # flag detail +shield flags create new-checkout boolean # create (interactive prompts follow) +shield flags enable new-checkout # enable (kill-switch off) +shield flags disable new-checkout # disable (kill-switch on) +shield flags delete new-checkout # permanently delete + +shield flags eval new-checkout --user user_123 # evaluate for a user + +shield flags targeting new-checkout # show targeting rules +shield flags add-rule new-checkout \ + --variation on \ + --segment beta-users # add segment-based rule +shield flags add-rule new-checkout \ + --variation on \ + --attribute plan --operator in --values pro,enterprise +shield flags remove-rule new-checkout --rule-id + +shield flags add-prereq new-checkout --flag auth-v2 --variation enabled +shield flags remove-prereq new-checkout --flag auth-v2 + +shield flags target new-checkout --variation on --context-key user_123 +shield flags untarget new-checkout --context-key user_123 + +shield flags variations new-checkout # list variations +shield flags edit new-checkout # open interactive editor +``` + +### `shield segments` + +```bash +shield segments list +shield segments get beta-users +shield segments create beta_users --name "Beta Users" +shield segments include beta_users --context-key user_123,user_456 +shield segments exclude beta_users --context-key opted_out +shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise +shield segments remove-rule beta_users --rule-id +shield segments delete beta_users +``` + +--- + +## Full example + +Full example at [`examples/fastapi/feature_flags.py`](https://github.com/Attakay78/api-shield/blob/main/examples/fastapi/feature_flags.py), covering all five flag types, individual targeting, attribute-based rules, percentage rollouts, and sync and async evaluation. + +Run it with: + +```bash +uv run uvicorn examples.fastapi.feature_flags:app --reload +``` + +Then visit: + +- `http://localhost:8000/docs` — Swagger UI +- `http://localhost:8000/shield/flags` — flag management dashboard +- `http://localhost:8000/checkout?user_id=beta_tester_1` — targeted user (always `"on"`) +- `http://localhost:8000/checkout?user_id=anyone_else` — 20% rollout + +--- + +## Next step + +[**Reference: Feature Flags →**](../reference/feature-flags.md) diff --git a/docs/tutorial/installation.md b/docs/tutorial/installation.md index 962b982..828c7bd 100644 --- a/docs/tutorial/installation.md +++ b/docs/tutorial/installation.md @@ -22,6 +22,9 @@ uv add "api-shield[fastapi,cli]" # FastAPI + rate limiting uv add "api-shield[fastapi,rate-limit]" +# FastAPI + feature flags +uv add "api-shield[fastapi,flags]" + # Everything (FastAPI adapter, Redis, dashboard, CLI, admin, rate limiting) uv add "api-shield[all]" ``` @@ -44,6 +47,7 @@ pip install "api-shield[all]" | `admin` | Unified `ShieldAdmin` (dashboard + REST API) | Recommended for CLI support | | `cli` | `shield` command-line tool + httpx client | Operators managing routes from the terminal | | `rate-limit` | `limits` library for `@rate_limit` enforcement | Any app using rate limiting | +| `flags` | `openfeature-sdk` + `packaging` for the feature flag system | Any app using feature flags | | `all` | All of the above | Easiest option for most projects | --- @@ -83,6 +87,7 @@ SHIELD_SERVER_URL=http://localhost:8000/shield --- -## Next step +## Next steps -[**Tutorial: Your first decorator →**](first-decorator.md) +- [**Tutorial: Your first decorator →**](first-decorator.md) +- [**Tutorial: Feature Flags →**](feature-flags.md) diff --git a/examples/fastapi/feature_flags.py b/examples/fastapi/feature_flags.py new file mode 100644 index 0000000..207f80d --- /dev/null +++ b/examples/fastapi/feature_flags.py @@ -0,0 +1,434 @@ +"""FastAPI — Feature Flags Example. + +Demonstrates the full feature-flag API powered by OpenFeature: + + * Boolean / string / integer / float / JSON flag types + * Async evaluation (``await engine.flag_client.get_boolean_value(...)``) + * Sync evaluation (``engine.sync.flag_client.get_boolean_value(...)``) + * EvaluationContext — per-request targeting based on user attributes + * Individual targeting — specific users always get a fixed variation + * Targeting rules — serve variations based on plan, country, app_version + * Percentage rollout (fallthrough) — gradual feature release + * Kill-switch — disable a flag globally without redeploying + * Live event stream — watch evaluations in real time + +Prerequisites: + pip install api-shield[flags] + # or: + uv pip install "api-shield[flags]" + +Run: + uv run uvicorn examples.fastapi.feature_flags:app --reload + +Then visit: + http://localhost:8000/docs — Swagger UI + http://localhost:8000/shield/ — admin dashboard (login: admin / secret) + http://localhost:8000/shield/flags — flag management UI + +Exercise the endpoints: + # Boolean flag — new checkout flow (async route) + curl "http://localhost:8000/checkout?user_id=user_123" + + # Boolean flag — new checkout flow (sync/def route) + curl "http://localhost:8000/checkout/sync?user_id=user_123" + + # String flag — UI theme selection + curl "http://localhost:8000/theme?user_id=beta_user_1" + + # Integer flag — max results per page + curl "http://localhost:8000/search?user_id=pro_user_1&plan=pro" + + # Float flag — discount rate for a country segment + curl "http://localhost:8000/pricing?user_id=uk_user_1&country=GB" + + # JSON flag — feature configuration bundle + curl "http://localhost:8000/config?user_id=user_123" + + # Targeting: individual user always gets the beta variation + curl "http://localhost:8000/checkout?user_id=beta_tester_1" + + # Live event stream (SSE) — watch evaluations happen in real time + curl -N "http://localhost:8000/shield/api/flags/stream" + +CLI — manage flags without redeploying: + shield login admin # password: secret + shield flags list + shield flags get new-checkout + shield flags disable new-checkout # kill-switch + shield flags enable new-checkout # restore + shield flags stream # tail live evaluations + shield flags stream new-checkout # filter to one flag +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Request + +from shield.admin import ShieldAdmin +from shield.core.config import make_engine +from shield.core.feature_flags.models import ( + EvaluationContext, + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RolloutVariation, + RuleClause, + TargetingRule, +) +from shield.fastapi import ( + ShieldMiddleware, + ShieldRouter, + apply_shield_to_openapi, +) + +# --------------------------------------------------------------------------- +# Engine setup +# --------------------------------------------------------------------------- + +engine = make_engine() +engine.use_openfeature() + +router = ShieldRouter(engine=engine) + + +# --------------------------------------------------------------------------- +# Seed flags at startup +# --------------------------------------------------------------------------- + + +async def _seed_flags() -> None: + """Register all feature flags. + + In production you would persist flags to a shared backend (Redis, file) + or manage them via the dashboard / REST API. This function is for + demonstration only — flags created here exist only in memory. + """ + + # ------------------------------------------------------------------ + # 1. Boolean flag — new checkout flow + # + # Individual targeting: beta_tester_1 always sees the new flow. + # Fallthrough: 20% of remaining users get "on", 80% get "off". + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="new-checkout", + name="New Checkout Flow", + description="Gradual rollout of the redesigned checkout experience.", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True, description="New flow enabled"), + FlagVariation(name="off", value=False, description="Legacy flow"), + ], + off_variation="off", + # 20 % rollout — weights out of 100_000 + fallthrough=[ + RolloutVariation(variation="on", weight=20_000), + RolloutVariation(variation="off", weight=80_000), + ], + targets={"on": ["beta_tester_1", "beta_tester_2"]}, + ) + ) + + # ------------------------------------------------------------------ + # 2. String flag — UI theme + # + # Rule: users whose email ends with "@acme.com" always get "dark". + # Fallthrough: everyone else gets "light". + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="ui-theme", + name="UI Theme", + description="Default UI theme served to users.", + type=FlagType.STRING, + variations=[ + FlagVariation(name="light", value="light"), + FlagVariation(name="dark", value="dark"), + FlagVariation(name="system", value="system"), + ], + off_variation="light", + fallthrough="light", + rules=[ + TargetingRule( + description="Corporate users → dark theme", + clauses=[ + RuleClause( + attribute="email", + operator=Operator.ENDS_WITH, + values=["@acme.com"], + ) + ], + variation="dark", + ) + ], + ) + ) + + # ------------------------------------------------------------------ + # 3. Integer flag — search results per page + # + # Rule: "pro" and "enterprise" plans get 50 results. + # Fallthrough: free-tier users get 10. + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="search-page-size", + name="Search Page Size", + description="Max results returned per search request.", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="small", value=10, description="Free tier"), + FlagVariation(name="large", value=50, description="Pro / enterprise"), + ], + off_variation="small", + fallthrough="small", + rules=[ + TargetingRule( + description="Paid plans → large page size", + clauses=[ + RuleClause( + attribute="plan", + operator=Operator.IN, + values=["pro", "enterprise"], + ) + ], + variation="large", + ) + ], + ) + ) + + # ------------------------------------------------------------------ + # 4. Float flag — regional discount rate + # + # Rule: GB users get a 15 % discount. + # Rule: EU users get a 10 % discount. + # Fallthrough: no discount (0.0). + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="discount-rate", + name="Regional Discount Rate", + description="Fractional discount applied at checkout (0.0 = none, 0.15 = 15%).", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="none", value=0.0), + FlagVariation(name="eu", value=0.10), + FlagVariation(name="gb", value=0.15), + ], + off_variation="none", + fallthrough="none", + rules=[ + TargetingRule( + description="GB → 15% discount", + clauses=[RuleClause(attribute="country", operator=Operator.IS, values=["GB"])], + variation="gb", + ), + TargetingRule( + description="EU → 10% discount", + clauses=[ + RuleClause( + attribute="country", + operator=Operator.IN, + values=["DE", "FR", "NL", "SE", "PL"], + ) + ], + variation="eu", + ), + ], + ) + ) + + # ------------------------------------------------------------------ + # 5. JSON flag — feature configuration bundle + # + # Returns a structured dict with multiple settings in one round-trip. + # Useful for feature bundles that require several related values. + # ------------------------------------------------------------------ + await engine.save_flag( + FeatureFlag( + key="feature-config", + name="Feature Configuration Bundle", + description="Combined config object for the new dashboard experience.", + type=FlagType.JSON, + variations=[ + FlagVariation( + name="v2", + value={ + "sidebar": True, + "analytics": True, + "export_formats": ["csv", "xlsx", "json"], + "max_widgets": 20, + }, + description="Full v2 dashboard", + ), + FlagVariation( + name="v1", + value={ + "sidebar": False, + "analytics": False, + "export_formats": ["csv"], + "max_widgets": 5, + }, + description="Legacy v1 dashboard", + ), + ], + off_variation="v1", + fallthrough="v1", + ) + ) + + +# --------------------------------------------------------------------------- +# Routes — async (def async) +# --------------------------------------------------------------------------- + + +@router.get("/checkout") +async def checkout(request: Request, user_id: str = "anonymous"): + """Async route: evaluate the boolean ``new-checkout`` flag. + + Pass ``?user_id=beta_tester_1`` to see individual targeting in action. + The flag is on a 20 % rollout for everyone else. + """ + ctx = EvaluationContext(key=user_id) + enabled = await engine.flag_client.get_boolean_value("new-checkout", False, ctx) + return { + "user_id": user_id, + "new_checkout": enabled, + "flow": "v2" if enabled else "v1", + } + + +@router.get("/theme") +async def theme(request: Request, user_id: str = "anonymous", email: str = ""): + """Async route: evaluate the string ``ui-theme`` flag. + + Pass ``?email=you@acme.com`` to trigger the corporate-user rule. + """ + ctx = EvaluationContext(key=user_id, email=email or None) + selected_theme = await engine.flag_client.get_string_value("ui-theme", "light", ctx) + return {"user_id": user_id, "theme": selected_theme} + + +@router.get("/search") +async def search(request: Request, user_id: str = "anonymous", plan: str = "free"): + """Async route: evaluate the integer ``search-page-size`` flag. + + Pass ``?plan=pro`` or ``?plan=enterprise`` to get the larger page size. + """ + ctx = EvaluationContext(key=user_id, attributes={"plan": plan}) + page_size = await engine.flag_client.get_integer_value("search-page-size", 10, ctx) + return {"user_id": user_id, "plan": plan, "page_size": page_size, "results": []} + + +@router.get("/pricing") +async def pricing(request: Request, user_id: str = "anonymous", country: str = "US"): + """Async route: evaluate the float ``discount-rate`` flag. + + Pass ``?country=GB`` (15 %) or ``?country=DE`` (10 %). + """ + ctx = EvaluationContext(key=user_id, country=country) + discount = await engine.flag_client.get_float_value("discount-rate", 0.0, ctx) + return { + "user_id": user_id, + "country": country, + "discount_rate": discount, + "price_usd": round(100.0 * (1 - discount), 2), + } + + +@router.get("/config") +async def config(request: Request, user_id: str = "anonymous"): + """Async route: evaluate the JSON ``feature-config`` flag. + + Returns the entire configuration bundle in a single evaluation call. + """ + ctx = EvaluationContext(key=user_id) + cfg: Any = await engine.flag_client.get_object_value( + "feature-config", {"sidebar": False, "analytics": False}, ctx + ) + return {"user_id": user_id, "config": cfg} + + +# --------------------------------------------------------------------------- +# Routes — sync (def, no async) +# --------------------------------------------------------------------------- +# FastAPI runs plain ``def`` handlers in a thread pool. +# ``engine.sync.flag_client`` provides a thread-safe synchronous facade over +# the same OpenFeature client — no asyncio bridge needed because flag +# evaluation is pure Python with no I/O. +# --------------------------------------------------------------------------- + + +@router.get("/checkout/sync") +def checkout_sync(request: Request, user_id: str = "anonymous"): + """Sync route: evaluate the ``new-checkout`` flag from a ``def`` handler. + + Identical result to ``GET /checkout`` — use whichever matches your handler style. + """ + enabled = engine.sync.flag_client.get_boolean_value( + "new-checkout", False, {"targeting_key": user_id} + ) + return { + "user_id": user_id, + "new_checkout": enabled, + "flow": "v2" if enabled else "v1", + "evaluated_in": "sync", + } + + +@router.get("/search/sync") +def search_sync(request: Request, user_id: str = "anonymous", plan: str = "free"): + """Sync route: evaluate the ``search-page-size`` flag from a ``def`` handler.""" + page_size = engine.sync.flag_client.get_integer_value( + "search-page-size", 10, {"targeting_key": user_id, "plan": plan} + ) + return { + "user_id": user_id, + "plan": plan, + "page_size": page_size, + "evaluated_in": "sync", + } + + +# --------------------------------------------------------------------------- +# App assembly +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await _seed_flags() + yield + + +app = FastAPI( + title="api-shield — Feature Flags Example", + description=( + "Demonstrates boolean, string, integer, float, and JSON flags with " + "targeting rules, rollouts, kill-switches, and live event streaming.\n\n" + "Requires `api-shield[flags]` (`pip install api-shield[flags]`)." + ), + lifespan=lifespan, +) + +app.add_middleware(ShieldMiddleware, engine=engine) +app.include_router(router) +apply_shield_to_openapi(app, engine) + +app.mount( + "/shield", + ShieldAdmin( + engine=engine, + auth=("admin", "secret"), + prefix="/shield", + # enable_flags is auto-detected from engine.use_openfeature() — no + # need to set it explicitly. Set to True/False to override. + ), +) diff --git a/examples/fastapi/multi_service.py b/examples/fastapi/multi_service.py index bf015a9..a999563 100644 --- a/examples/fastapi/multi_service.py +++ b/examples/fastapi/multi_service.py @@ -8,13 +8,13 @@ This file defines THREE separate ASGI apps. Run each in its own terminal: Shield Server (port 8001): - uv run uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload + uv run --with uvicorn uvicorn examples.fastapi.multi_service:shield_app --port 8001 --reload Payments service (port 8000): - uv run uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload + uv run --with uvicorn uvicorn examples.fastapi.multi_service:payments_app --port 8000 --reload Orders service (port 8002): - uv run uvicorn examples.fastapi.multi_service:orders_app --port 8002 --reload + uv run --with uvicorn uvicorn examples.fastapi.multi_service:orders_app --port 8002 --reload Then visit: http://localhost:8001/ — Shield dashboard (admin / secret) @@ -98,6 +98,7 @@ disabled, force_active, maintenance, + setup_shield_docs, ) from shield.sdk import ShieldSDK from shield.server import ShieldServer @@ -134,6 +135,8 @@ payments_sdk = ShieldSDK( server_url="http://localhost:8001", app_id="payments-service", + username="admin", + password="secret", # Auto-login (recommended): SDK obtains a 1-year sdk-platform token on startup. # username="admin", # inject from env: os.environ["SHIELD_USERNAME"] # password="secret", # inject from env: os.environ["SHIELD_PASSWORD"] @@ -201,6 +204,7 @@ async def v2_invoices(): payments_app.include_router(payments_router) apply_shield_to_openapi(payments_app, payments_sdk.engine) +setup_shield_docs(payments_app, payments_sdk.engine) # --------------------------------------------------------------------------- # Orders Service (port 8002) @@ -213,6 +217,8 @@ async def v2_invoices(): orders_sdk = ShieldSDK( server_url="http://localhost:8001", app_id="orders-service", + username="admin", + password="secret", # Auto-login (recommended): SDK obtains a 1-year sdk-platform token on startup. # username="admin", # inject from env: os.environ["SHIELD_USERNAME"] # password="secret", # inject from env: os.environ["SHIELD_PASSWORD"] @@ -278,6 +284,7 @@ async def get_cart(): orders_app.include_router(orders_router) apply_shield_to_openapi(orders_app, orders_sdk.engine) +setup_shield_docs(orders_app, orders_sdk.engine) # --------------------------------------------------------------------------- # CLI reference — multi-service workflow diff --git a/examples/fastapi/shield_server.py b/examples/fastapi/shield_server.py index 6d9798d..bc0e556 100644 --- a/examples/fastapi/shield_server.py +++ b/examples/fastapi/shield_server.py @@ -133,6 +133,8 @@ sdk = ShieldSDK( server_url="http://localhost:8001", app_id="my-service", + username="admin", + password="secret", # username="admin", # or inject from env: os.environ["SHIELD_USERNAME"] # password="secret", # or inject from env: os.environ["SHIELD_PASSWORD"] reconnect_delay=5.0, # seconds between SSE reconnect attempts diff --git a/mkdocs.yml b/mkdocs.yml index 77f927a..ba7bd43 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,11 +98,13 @@ nav: - Adding Middleware: tutorial/middleware.md - Backends: tutorial/backends.md - Rate Limiting: tutorial/rate-limiting.md + - Feature Flags: tutorial/feature-flags.md - Admin Dashboard: tutorial/admin-dashboard.md - CLI: tutorial/cli.md - Reference: - Decorators: reference/decorators.md - Rate Limiting: reference/rate-limiting.md + - Feature Flags: reference/feature-flags.md - ShieldEngine: reference/engine.md - Backends: reference/backends.md - Middleware: reference/middleware.md diff --git a/pyproject.toml b/pyproject.toml index 83e6a74..af82cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", "anyio>=4.0", - "starlette>=0.27", + "starlette>=0.40", ] [project.urls] @@ -33,7 +33,7 @@ Repository = "https://github.com/Attakay78/api-shield" Issues = "https://github.com/Attakay78/api-shield/issues" [project.optional-dependencies] -fastapi = ["fastapi>=0.100"] +fastapi = ["fastapi>=0.115"] redis = ["redis[asyncio]>=5.0"] dashboard = [ "jinja2>=3.1", @@ -49,8 +49,12 @@ admin = [ yaml = ["pyyaml>=6.0"] toml = ["tomli-w>=1.0"] rate-limit = ["limits>=5.8.0"] +flags = [ + "openfeature-sdk>=0.8", + "packaging>=23.0", +] all = [ - "fastapi>=0.100", + "fastapi>=0.115", "redis[asyncio]>=5.0", "jinja2>=3.1", "aiofiles>=23.0", @@ -61,6 +65,8 @@ all = [ "tomli-w>=1.0", "python-multipart>=0.0.22", "limits>=5.8.0", + "openfeature-sdk>=0.8", + "packaging>=23.0", ] docs = [ "mkdocs-material>=9.5", @@ -75,11 +81,13 @@ dev = [ "ruff", "mypy", "aiofiles>=23.0", - "fastapi>=0.100", + "fastapi>=0.115", "pre-commit>=3.7", "tomli-w>=1.0", "pyyaml>=6.0", "limits>=5.8.0", + "openfeature-sdk>=0.8", + "packaging>=23.0", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.25", "mkdocs-git-revision-date-localized-plugin>=1.2", @@ -106,6 +114,11 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP"] +[tool.ruff.lint.per-file-ignores] +"shield/core/feature_flags/__init__.py" = ["E402"] +"shield/core/feature_flags/hooks.py" = ["E402"] +"shield/core/feature_flags/provider.py" = ["E402"] + [tool.mypy] python_version = "3.11" strict = true diff --git a/shield/admin/api.py b/shield/admin/api.py index 6fa1a65..3736015 100644 --- a/shield/admin/api.py +++ b/shield/admin/api.py @@ -588,11 +588,29 @@ async def _feed_rl_policies() -> None: except Exception: logger.exception("shield: SDK SSE RL policy subscription error") + async def _feed_flags() -> None: + try: + async for event in engine.backend.subscribe_flag_changes(): # type: ignore[attr-defined] + envelope = _json.dumps(event) + await queue.put(f"data: {envelope}\n\n") + except NotImplementedError: + pass + except asyncio.CancelledError: + raise + except Exception: + logger.exception("shield: SDK SSE flag subscription error") + async def _generate() -> object: tasks.append(asyncio.create_task(_feed_states())) tasks.append(asyncio.create_task(_feed_rl_policies())) + tasks.append(asyncio.create_task(_feed_flags())) try: while True: + # Check for client disconnect before blocking on the queue. + # is_disconnected() polls receive() with a 1 ms timeout so it + # never blocks the loop for more than a millisecond. + if await request.is_disconnected(): + break try: # Block until an event arrives or 15 s elapses. msg = await asyncio.wait_for(queue.get(), timeout=15.0) @@ -605,6 +623,10 @@ async def _generate() -> object: finally: for t in tasks: t.cancel() + # Await the feeder tasks so their finally blocks (which deregister + # subscriber queues) run before this handler returns. Errors are + # suppressed — we only care that cleanup completes. + await asyncio.gather(*tasks, return_exceptions=True) return StreamingResponse( _generate(), # type: ignore[arg-type] @@ -704,3 +726,338 @@ async def sdk_audit(request: Request) -> JSONResponse: await engine.backend.write_audit(entry) return JSONResponse({"ok": True}) + + +# --------------------------------------------------------------------------- +# Feature flag endpoints +# --------------------------------------------------------------------------- +# +# These endpoints are only mounted when ShieldAdmin(enable_flags=True). +# They require the [flags] optional extra to be installed — callers get a +# clear 501 error if the extra is missing. +# --------------------------------------------------------------------------- + + +def _flags_not_configured() -> JSONResponse: + return JSONResponse( + { + "error": ( + "Feature flags are not enabled. " + "Call engine.use_openfeature() and set enable_flags=True on ShieldAdmin." + ) + }, + status_code=501, + ) + + +def _flags_not_installed() -> JSONResponse: + return JSONResponse( + { + "error": ( + "Feature flags require the [flags] extra. " + "Install with: pip install api-shield[flags]" + ) + }, + status_code=501, + ) + + +def _flag_models_available() -> bool: + """Return True if the openfeature extra is installed.""" + try: + import openfeature # noqa: F401 + + return True + except ImportError: + return False + + +async def list_flags(request: Request) -> JSONResponse: + """GET /api/flags — list all feature flags.""" + if not _flag_models_available(): + return _flags_not_installed() + flags = await _engine(request).list_flags() + return JSONResponse([f.model_dump(mode="json") for f in flags]) + + +async def get_flag(request: Request) -> JSONResponse: + """GET /api/flags/{key} — get a single feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + return JSONResponse(flag.model_dump(mode="json")) + + +async def create_flag(request: Request) -> JSONResponse: + """POST /api/flags — create a new feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + try: + from shield.core.feature_flags.models import FeatureFlag + + flag = FeatureFlag.model_validate(body) + except Exception as exc: + return _err(f"Invalid flag definition: {exc}") + + # Conflict check + existing = await _engine(request).get_flag(flag.key) + if existing is not None: + return _err(f"Flag '{flag.key}' already exists. Use PUT to update.", 409) + + await _engine(request).save_flag(flag) + return JSONResponse(flag.model_dump(mode="json"), status_code=201) + + +async def update_flag(request: Request) -> JSONResponse: + """PUT /api/flags/{key} — replace a feature flag (full update).""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + # Key in URL must match key in body if provided. + if isinstance(body, dict) and body.get("key", key) != key: + return _err("Flag key in URL and body must match") + + if isinstance(body, dict): + body["key"] = key + + try: + from shield.core.feature_flags.models import FeatureFlag + + flag = FeatureFlag.model_validate(body) + except Exception as exc: + return _err(f"Invalid flag definition: {exc}") + + await _engine(request).save_flag(flag) + return JSONResponse(flag.model_dump(mode="json")) + + +async def patch_flag(request: Request) -> JSONResponse: + """PATCH /api/flags/{key} — partial update of a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + if not isinstance(body, dict): + return _err("Body must be a JSON object") + + # Never allow patching immutable fields + for immutable in ("key", "type"): + body.pop(immutable, None) + + try: + from shield.core.feature_flags.models import FeatureFlag + + # Build updated flag by merging patch onto existing + current = flag.model_dump(mode="python") + current.update(body) + updated = FeatureFlag.model_validate(current) + except Exception as exc: + return _err(f"Invalid patch: {exc}") + + # Cross-field validation: off_variation and string fallthrough must name + # an existing variation (the model doesn't enforce this itself). + variation_names = {v.name for v in updated.variations} + if updated.off_variation not in variation_names: + return _err(f"off_variation '{updated.off_variation}' does not match any variation name") + if isinstance(updated.fallthrough, str) and updated.fallthrough not in variation_names: + return _err(f"fallthrough '{updated.fallthrough}' does not match any variation name") + + await _engine(request).save_flag(updated) + return JSONResponse(updated.model_dump(mode="json")) + + +async def enable_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/enable — enable a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + flag = flag.model_copy(update={"enabled": True}) + await _engine(request).save_flag(flag) + return JSONResponse(flag.model_dump(mode="json")) + + +async def disable_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/disable — disable a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + flag = flag.model_copy(update={"enabled": False}) + await _engine(request).save_flag(flag) + return JSONResponse(flag.model_dump(mode="json")) + + +async def delete_flag(request: Request) -> JSONResponse: + """DELETE /api/flags/{key} — delete a feature flag.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + existing = await _engine(request).get_flag(key) + if existing is None: + return _err(f"Flag '{key}' not found", 404) + await _engine(request).delete_flag(key) + return JSONResponse({"ok": True, "deleted": key}) + + +async def evaluate_flag(request: Request) -> JSONResponse: + """POST /api/flags/{key}/evaluate — evaluate a flag for a given context. + + Body: ``{"default": , "context": {"key": "user_1", "attributes": {...}}}`` + + Returns the resolved value, variation, reason, and any metadata. + Useful for debugging targeting rules from the dashboard or CLI. + """ + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + + flag = await _engine(request).get_flag(key) + if flag is None: + return _err(f"Flag '{key}' not found", 404) + + try: + body = await request.json() + except Exception: + body = {} + + ctx_data = body.get("context", {}) if isinstance(body, dict) else {} + + try: + from shield.core.feature_flags.evaluator import FlagEvaluator + from shield.core.feature_flags.models import EvaluationContext + + ctx = EvaluationContext.model_validate({"key": "anonymous", **ctx_data}) + engine = _engine(request) + # Gather all flags and segments from the engine for prerequisite resolution. + all_flags_list = await engine.list_flags() + all_flags = {f.key: f for f in all_flags_list} + segments_list = await engine.list_segments() + segments = {s.key: s for s in segments_list} + + evaluator = FlagEvaluator(segments=segments) + result = evaluator.evaluate(flag, ctx, all_flags) + except Exception as exc: + return _err(f"Evaluation error: {exc}", 500) + + return JSONResponse( + { + "flag_key": key, + "value": result.value, + "variation": result.variation, + "reason": result.reason.value, + "rule_id": result.rule_id, + "prerequisite_key": result.prerequisite_key, + "error_message": result.error_message, + } + ) + + +# --------------------------------------------------------------------------- +# Segment endpoints +# --------------------------------------------------------------------------- + + +async def list_segments(request: Request) -> JSONResponse: + """GET /api/segments — list all segments.""" + if not _flag_models_available(): + return _flags_not_installed() + segments = await _engine(request).list_segments() + return JSONResponse([s.model_dump(mode="json") for s in segments]) + + +async def get_segment(request: Request) -> JSONResponse: + """GET /api/segments/{key} — get a single segment.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + segment = await _engine(request).get_segment(key) + if segment is None: + return _err(f"Segment '{key}' not found", 404) + return JSONResponse(segment.model_dump(mode="json")) + + +async def create_segment(request: Request) -> JSONResponse: + """POST /api/segments — create a new segment.""" + if not _flag_models_available(): + return _flags_not_installed() + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + try: + from shield.core.feature_flags.models import Segment + + segment = Segment.model_validate(body) + except Exception as exc: + return _err(f"Invalid segment definition: {exc}") + + existing = await _engine(request).get_segment(segment.key) + if existing is not None: + return _err(f"Segment '{segment.key}' already exists. Use PUT to update.", 409) + + await _engine(request).save_segment(segment) + return JSONResponse(segment.model_dump(mode="json"), status_code=201) + + +async def update_segment(request: Request) -> JSONResponse: + """PUT /api/segments/{key} — replace a segment (full update).""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + try: + body = await request.json() + except Exception: + return _err("Invalid JSON body") + + if isinstance(body, dict) and body.get("key", key) != key: + return _err("Segment key in URL and body must match") + + if isinstance(body, dict): + body["key"] = key + + try: + from shield.core.feature_flags.models import Segment + + segment = Segment.model_validate(body) + except Exception as exc: + return _err(f"Invalid segment definition: {exc}") + + await _engine(request).save_segment(segment) + return JSONResponse(segment.model_dump(mode="json")) + + +async def delete_segment(request: Request) -> JSONResponse: + """DELETE /api/segments/{key} — delete a segment.""" + if not _flag_models_available(): + return _flags_not_installed() + key = request.path_params["key"] + existing = await _engine(request).get_segment(key) + if existing is None: + return _err(f"Segment '{key}' not found", 404) + await _engine(request).delete_segment(key) + return JSONResponse({"ok": True, "deleted": key}) diff --git a/shield/admin/app.py b/shield/admin/app.py index 39e773a..50af3d7 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -211,6 +211,70 @@ async def _logout(request: Request) -> Response: return response +def _flag_dashboard_modal_routes() -> list[Route]: + """Flag + segment modal routes that must be registered BEFORE the generic wildcard. + + These must appear before ``Route("/modal/{action}/{path_key}", ...)`` in the + route list so Starlette's first-match routing picks the specific handler. + """ + return [ + Route("/modal/flag/create", _dash.modal_flag_create, methods=["GET"]), + Route("/modal/flag/{key}/eval", _dash.modal_flag_eval, methods=["GET"]), + Route("/modal/segment/create", _dash.modal_segment_create, methods=["GET"]), + Route("/modal/segment/{key}/view", _dash.modal_segment_view, methods=["GET"]), + Route("/modal/segment/{key}", _dash.modal_segment_detail, methods=["GET"]), + ] + + +def _flag_dashboard_routes() -> list[Route]: + """Return the flag + segment dashboard UI routes for mounting in ShieldAdmin.""" + return [ + Route("/flags", _dash.flags_page, methods=["GET"]), + Route("/flags/rows", _dash.flags_rows_partial, methods=["GET"]), + Route("/flags/create", _dash.flag_create_form, methods=["POST"]), + Route("/flags/{key}", _dash.flag_detail_page, methods=["GET"]), + Route("/flags/{key}/settings/save", _dash.flag_settings_save, methods=["POST"]), + Route("/flags/{key}/variations/save", _dash.flag_variations_save, methods=["POST"]), + Route("/flags/{key}/targeting/save", _dash.flag_targeting_save, methods=["POST"]), + Route("/flags/{key}/prerequisites/save", _dash.flag_prerequisites_save, methods=["POST"]), + Route("/flags/{key}/targets/save", _dash.flag_targets_save, methods=["POST"]), + Route("/flags/{key}/enable", _dash.flag_enable, methods=["POST"]), + Route("/flags/{key}/disable", _dash.flag_disable, methods=["POST"]), + Route("/flags/{key}", _dash.flag_delete, methods=["DELETE"]), + Route("/flags/{key}/eval", _dash.flag_eval_form, methods=["POST"]), + Route("/segments", _dash.segments_page, methods=["GET"]), + Route("/segments/rows", _dash.segments_rows_partial, methods=["GET"]), + Route("/segments/create", _dash.segment_create_form, methods=["POST"]), + Route("/segments/{key}/rules/add", _dash.segment_rule_add, methods=["POST"]), + Route("/segments/{key}/rules/{rule_id}", _dash.segment_rule_delete, methods=["DELETE"]), + Route("/segments/{key}/save", _dash.segment_save_form, methods=["POST"]), + Route("/segments/{key}", _dash.modal_segment_detail, methods=["GET"]), + Route("/segments/{key}", _dash.segment_delete, methods=["DELETE"]), + ] + + +def _flag_routes() -> list[Route]: + """Return the flag + segment API routes for mounting in ShieldAdmin.""" + return [ + # ── Flags CRUD ─────────────────────────────────────────────── + Route("/api/flags", _api.list_flags, methods=["GET"]), + Route("/api/flags", _api.create_flag, methods=["POST"]), + Route("/api/flags/{key}", _api.get_flag, methods=["GET"]), + Route("/api/flags/{key}", _api.update_flag, methods=["PUT"]), + Route("/api/flags/{key}", _api.patch_flag, methods=["PATCH"]), + Route("/api/flags/{key}", _api.delete_flag, methods=["DELETE"]), + Route("/api/flags/{key}/enable", _api.enable_flag, methods=["POST"]), + Route("/api/flags/{key}/disable", _api.disable_flag, methods=["POST"]), + Route("/api/flags/{key}/evaluate", _api.evaluate_flag, methods=["POST"]), + # ── Segments CRUD ──────────────────────────────────────────── + Route("/api/segments", _api.list_segments, methods=["GET"]), + Route("/api/segments", _api.create_segment, methods=["POST"]), + Route("/api/segments/{key}", _api.get_segment, methods=["GET"]), + Route("/api/segments/{key}", _api.update_segment, methods=["PUT"]), + Route("/api/segments/{key}", _api.delete_segment, methods=["DELETE"]), + ] + + def ShieldAdmin( engine: ShieldEngine, auth: AuthConfig = None, @@ -218,6 +282,7 @@ def ShieldAdmin( sdk_token_expiry: int = 31536000, secret_key: str | None = None, prefix: str = "/shield", + enable_flags: bool | None = None, ) -> ASGIApp: """Create the unified Shield admin ASGI app. @@ -249,6 +314,13 @@ def ShieldAdmin( prefix: URL prefix at which the admin app is mounted. Must match the path passed to ``app.mount()``. Used to build correct redirects. + enable_flags: + When ``True``, mount the feature flag and segment dashboard UI and + REST API endpoints (``/flags/*``, ``/api/flags/*``, ``/api/segments/*``). + Requires ``engine.use_openfeature()`` to have been called and + ``api-shield[flags]`` to be installed. + When ``None`` (default), auto-detected: flags are enabled when + ``engine.use_openfeature()`` has been called. Returns ------- @@ -257,6 +329,10 @@ def ShieldAdmin( """ import base64 + # Auto-detect flags: enabled when engine.use_openfeature() has been called. + if enable_flags is None: + enable_flags = getattr(engine, "_flag_client", None) is not None + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) templates.env.filters["encode_path"] = lambda p: ( base64.urlsafe_b64encode(p.encode()).decode().rstrip("=") @@ -286,6 +362,7 @@ def _clean_entry_path(entry: object) -> str: templates.env.filters["clean_path"] = _clean_path templates.env.filters["clean_entry_path"] = _clean_entry_path + templates.env.globals["flags_enabled"] = enable_flags try: version = importlib.metadata.version("api-shield") @@ -316,6 +393,8 @@ def _clean_entry_path(entry: object) -> str: Route("/modal/global-rl/delete", _dash.modal_global_rl_delete), Route("/modal/global-rl/reset", _dash.modal_global_rl_reset), Route("/modal/env/{path_key}", _dash.modal_env_gate), + # Flag/segment modals must come before the generic wildcard below. + *(_flag_dashboard_modal_routes() if enable_flags else []), Route("/modal/{action}/{path_key}", _dash.action_modal), Route( "/global-maintenance/enable", @@ -422,6 +501,9 @@ def _clean_entry_path(entry: object) -> str: Route("/api/sdk/audit", _api.sdk_audit, methods=["POST"]), # ── Service discovery ──────────────────────────────────────── Route("/api/services", _api.list_services, methods=["GET"]), + # ── Feature flags (mounted only when enable_flags=True) ────── + *(_flag_dashboard_routes() if enable_flags else []), + *(_flag_routes() if enable_flags else []), ], ) @@ -432,6 +514,7 @@ def _clean_entry_path(entry: object) -> str: starlette_app.state.version = version starlette_app.state.token_manager = token_manager starlette_app.state.auth_backend = auth_backend + starlette_app.state.flags_enabled = enable_flags # Wrap with auth middleware. return _AuthMiddleware(starlette_app, token_manager=token_manager, auth_backend=auth_backend) diff --git a/shield/cli/client.py b/shield/cli/client.py index b3025a7..23e5691 100644 --- a/shield/cli/client.py +++ b/shield/cli/client.py @@ -365,6 +365,102 @@ async def disable_global_rate_limit(self) -> dict[str, Any]: resp = await c.post("/api/global-rate-limit/disable") return cast(dict[str, Any], self._check(resp)) + # ── Feature flags ───────────────────────────────────────────────── + + async def list_flags(self) -> list[dict[str, Any]]: + """GET /api/flags — list all feature flags.""" + async with self._make_client() as c: + resp = await c.get("/api/flags") + return cast(list[dict[str, Any]], self._check(resp)) + + async def get_flag(self, key: str) -> dict[str, Any]: + """GET /api/flags/{key} — get a single feature flag.""" + async with self._make_client() as c: + resp = await c.get(f"/api/flags/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def create_flag(self, flag_data: dict[str, Any]) -> dict[str, Any]: + """POST /api/flags — create a new feature flag.""" + async with self._make_client() as c: + resp = await c.post("/api/flags", json=flag_data) + return cast(dict[str, Any], self._check(resp)) + + async def update_flag(self, key: str, flag_data: dict[str, Any]) -> dict[str, Any]: + """PUT /api/flags/{key} — replace a feature flag.""" + async with self._make_client() as c: + resp = await c.put(f"/api/flags/{key}", json=flag_data) + return cast(dict[str, Any], self._check(resp)) + + async def patch_flag(self, key: str, patch: dict[str, Any]) -> dict[str, Any]: + """PATCH /api/flags/{key} — partial update.""" + async with self._make_client() as c: + resp = await c.patch(f"/api/flags/{key}", json=patch) + return cast(dict[str, Any], self._check(resp)) + + async def enable_flag(self, key: str) -> dict[str, Any]: + """POST /api/flags/{key}/enable — enable a feature flag.""" + async with self._make_client() as c: + resp = await c.post(f"/api/flags/{key}/enable") + return cast(dict[str, Any], self._check(resp)) + + async def disable_flag(self, key: str) -> dict[str, Any]: + """POST /api/flags/{key}/disable — disable a feature flag.""" + async with self._make_client() as c: + resp = await c.post(f"/api/flags/{key}/disable") + return cast(dict[str, Any], self._check(resp)) + + async def delete_flag(self, key: str) -> dict[str, Any]: + """DELETE /api/flags/{key} — delete a feature flag.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/flags/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def evaluate_flag( + self, + key: str, + context: dict[str, Any], + default: Any = None, + ) -> dict[str, Any]: + """POST /api/flags/{key}/evaluate — evaluate a flag for a context.""" + async with self._make_client() as c: + resp = await c.post( + f"/api/flags/{key}/evaluate", + json={"context": context, "default": default}, + ) + return cast(dict[str, Any], self._check(resp)) + + # ── Segments ────────────────────────────────────────────────────── + + async def list_segments(self) -> list[dict[str, Any]]: + """GET /api/segments — list all segments.""" + async with self._make_client() as c: + resp = await c.get("/api/segments") + return cast(list[dict[str, Any]], self._check(resp)) + + async def get_segment(self, key: str) -> dict[str, Any]: + """GET /api/segments/{key} — get a single segment.""" + async with self._make_client() as c: + resp = await c.get(f"/api/segments/{key}") + return cast(dict[str, Any], self._check(resp)) + + async def create_segment(self, segment_data: dict[str, Any]) -> dict[str, Any]: + """POST /api/segments — create a new segment.""" + async with self._make_client() as c: + resp = await c.post("/api/segments", json=segment_data) + return cast(dict[str, Any], self._check(resp)) + + async def update_segment(self, key: str, segment_data: dict[str, Any]) -> dict[str, Any]: + """PUT /api/segments/{key} — replace a segment.""" + async with self._make_client() as c: + resp = await c.put(f"/api/segments/{key}", json=segment_data) + return cast(dict[str, Any], self._check(resp)) + + async def delete_segment(self, key: str) -> dict[str, Any]: + """DELETE /api/segments/{key} — delete a segment.""" + async with self._make_client() as c: + resp = await c.delete(f"/api/segments/{key}") + return cast(dict[str, Any], self._check(resp)) + def make_client( transport: httpx.AsyncBaseTransport | None = None, diff --git a/shield/cli/main.py b/shield/cli/main.py index fb3db91..f7ee7c0 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -1340,5 +1340,967 @@ async def _run_grl_disable() -> None: _run(_run_grl_disable) +# --------------------------------------------------------------------------- +# Feature flags command group (shield flags ...) +# --------------------------------------------------------------------------- + +_FLAG_TYPE_COLOURS = { + "boolean": "green", + "string": "cyan", + "integer": "blue", + "float": "blue", + "json": "magenta", +} + +flags_app = typer.Typer( + name="flags", + help="Manage feature flags.", + no_args_is_help=True, +) +cli.add_typer(flags_app, name="flags") + + +def _flag_status_colour(enabled: bool) -> str: + return "green" if enabled else "dim" + + +def _print_flags_table(flags: list[dict[str, Any]]) -> None: + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False) + tbl.add_column("Key", style="bold cyan", no_wrap=True) + tbl.add_column("Type", style="white") + tbl.add_column("Status", style="white") + tbl.add_column("Variations", style="dim") + tbl.add_column("Fallthrough", style="dim") + for f in flags: + enabled = f.get("enabled", True) + status_text = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]" + ftype = f.get("type", "") + colour = _FLAG_TYPE_COLOURS.get(ftype, "white") + variations = ", ".join(v["name"] for v in f.get("variations", [])) + fallthrough = f.get("fallthrough", "") + if isinstance(fallthrough, list): + fallthrough = "rollout" + tbl.add_row( + f.get("key", ""), + f"[{colour}]{ftype}[/{colour}]", + status_text, + variations, + str(fallthrough), + ) + console.print(tbl) + + +@flags_app.command("list") +def flags_list( + type: str = typer.Option("", "--type", "-t", help="Filter by flag type (boolean, string, …)"), + enabled: str = typer.Option("", "--status", "-s", help="Filter by status: enabled or disabled"), +) -> None: + """List all feature flags.""" + + async def _run_flags_list() -> None: + flags = await make_client().list_flags() + if type: + flags = [f for f in flags if f.get("type") == type] + if enabled == "enabled": + flags = [f for f in flags if f.get("enabled", True)] + elif enabled == "disabled": + flags = [f for f in flags if not f.get("enabled", True)] + if not flags: + console.print("[dim]No flags found.[/dim]") + return + _print_flags_table(flags) + console.print(f"[dim]{len(flags)} flag(s)[/dim]") + + _run(_run_flags_list) + + +@flags_app.command("get") +def flags_get(key: str = typer.Argument(..., help="Flag key")) -> None: + """Show details for a single feature flag.""" + + async def _run_flags_get() -> None: + flag = await make_client().get_flag(key) + console.print(f"[bold cyan]{flag['key']}[/bold cyan] [dim]{flag.get('name', '')}[/dim]") + ftype = flag.get("type", "") + colour = _FLAG_TYPE_COLOURS.get(ftype, "white") + enabled = flag.get("enabled", True) + status_text = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]" + console.print(f" Type: [{colour}]{ftype}[/{colour}]") + console.print(f" Status: {status_text}") + console.print(f" Off variation: [dim]{flag.get('off_variation', '')}[/dim]") + fallthrough = flag.get("fallthrough", "") + if isinstance(fallthrough, list): + parts = [f"{rv['variation']}:{rv['weight'] // 1000}%" for rv in fallthrough] + console.print(f" Fallthrough: [dim]{', '.join(parts)}[/dim]") + else: + console.print(f" Fallthrough: [dim]{fallthrough}[/dim]") + # Variations + console.print(" Variations:") + for v in flag.get("variations", []): + console.print(f" • [bold]{v['name']}[/bold] = {v['value']!r}") + # Rules + rules = flag.get("rules") or [] + if rules: + console.print(f" Rules: [dim]{len(rules)} targeting rule(s)[/dim]") + # Prerequisites + prereqs = flag.get("prerequisites") or [] + if prereqs: + console.print(" Prerequisites:") + for p in prereqs: + console.print( + f" • [cyan]{p['flag_key']}[/cyan] must be [bold]{p['variation']}[/bold]" + ) + + _run(_run_flags_get) + + +@flags_app.command("create") +def flags_create( + key: str = typer.Argument(..., help="Unique flag key (e.g. new_checkout)"), + name: str = typer.Option(..., "--name", "-n", help="Human-readable name"), + type: str = typer.Option( + "boolean", "--type", "-t", help="Flag type: boolean, string, integer, float, json" + ), + description: str = typer.Option("", "--description", "-d", help="Optional description"), +) -> None: + """Create a new boolean feature flag with on/off variations. + + For other types or advanced configuration, use the dashboard or the API + directly. The flag is created enabled with fallthrough=off. + + \b + shield flags create new_checkout --name "New Checkout Flow" + shield flags create dark_mode --name "Dark Mode" --type boolean + """ + + async def _run_flags_create() -> None: + flag_type = type.lower() + # Build default on/off variations based on type. + if flag_type == "boolean": + variations = [{"name": "on", "value": True}, {"name": "off", "value": False}] + off_variation = "off" + fallthrough = "off" + elif flag_type == "string": + variations = [ + {"name": "control", "value": "control"}, + {"name": "treatment", "value": "treatment"}, + ] + off_variation = "control" + fallthrough = "control" + elif flag_type in ("integer", "float"): + variations = [{"name": "off", "value": 0}, {"name": "on", "value": 1}] + off_variation = "off" + fallthrough = "off" + elif flag_type == "json": + variations = [{"name": "off", "value": {}}, {"name": "on", "value": {}}] + off_variation = "off" + fallthrough = "off" + else: + err_console.print( + f"[red]Error:[/red] Unknown type {type!r}. " + "Use boolean, string, integer, float, or json." + ) + raise typer.Exit(code=1) + + flag_data = { + "key": key, + "name": name, + "type": flag_type, + "description": description, + "variations": variations, + "off_variation": off_variation, + "fallthrough": fallthrough, + "enabled": True, + } + result = await make_client().create_flag(flag_data) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] created.") + + _run(_run_flags_create) + + +@flags_app.command("enable") +def flags_enable(key: str = typer.Argument(..., help="Flag key")) -> None: + """Enable a feature flag.""" + + async def _run_flags_enable() -> None: + result = await make_client().enable_flag(key) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] enabled.") + + _run(_run_flags_enable) + + +@flags_app.command("disable") +def flags_disable(key: str = typer.Argument(..., help="Flag key")) -> None: + """Disable a feature flag (serves the off variation to all users).""" + + async def _run_flags_disable() -> None: + result = await make_client().disable_flag(key) + console.print(f"[dim]✓ Flag {result['key']} disabled.[/dim]") + + _run(_run_flags_disable) + + +@flags_app.command("delete") +def flags_delete( + key: str = typer.Argument(..., help="Flag key"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Permanently delete a feature flag.""" + if not yes: + typer.confirm(f"Delete flag '{key}'? This cannot be undone.", abort=True) + + async def _run_flags_delete() -> None: + result = await make_client().delete_flag(key) + console.print(f"[green]✓[/green] Flag [bold]{result['deleted']}[/bold] deleted.") + + _run(_run_flags_delete) + + +@flags_app.command("eval") +def flags_eval( + key: str = typer.Argument(..., help="Flag key"), + ctx_key: str = typer.Option("anonymous", "--key", "-k", help="Context key (user ID)"), + kind: str = typer.Option("user", "--kind", help="Context kind"), + attr: list[str] = typer.Option([], "--attr", "-a", help="Attribute as key=value (repeatable)"), +) -> None: + """Evaluate a feature flag for a given context (debug tool). + + \b + shield flags eval new_checkout --key user_123 --attr role=admin --attr plan=pro + """ + + async def _run_flags_eval() -> None: + attributes: dict[str, str] = {} + for a in attr: + if "=" not in a: + err_console.print(f"[red]Error:[/red] Attribute must be key=value, got: {a!r}") + raise typer.Exit(code=1) + k, _, v = a.partition("=") + attributes[k.strip()] = v.strip() + + context = {"key": ctx_key, "kind": kind, "attributes": attributes} + result = await make_client().evaluate_flag(key, context) + + value = result.get("value") + variation = result.get("variation", "") + reason = result.get("reason", "") + rule_id = result.get("rule_id") + + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, show_header=False) + tbl.add_column("Field", style="dim", no_wrap=True) + tbl.add_column("Value", style="bold") + tbl.add_row("value", str(value)) + tbl.add_row("variation", variation or "—") + tbl.add_row("reason", reason) + if rule_id: + tbl.add_row("rule_id", rule_id) + prereq = result.get("prerequisite_key") + if prereq: + tbl.add_row("prerequisite_key", prereq) + err_msg = result.get("error_message") + if err_msg: + tbl.add_row("error", f"[red]{err_msg}[/red]") + console.print(tbl) + + _run(_run_flags_eval) + + +@flags_app.command("edit") +def flags_edit( + key: str = typer.Argument(..., help="Flag key"), + name: str | None = typer.Option(None, "--name", "-n", help="New display name"), + description: str | None = typer.Option(None, "--description", "-d", help="New description"), + off_variation: str | None = typer.Option( + None, "--off-variation", help="Variation served when flag is disabled" + ), + fallthrough: str | None = typer.Option( + None, "--fallthrough", help="Default variation when no rule matches" + ), +) -> None: + """Patch a feature flag (partial update — only provided fields are changed). + + \b + shield flags edit dark_mode --name "Dark Mode v2" + shield flags edit dark_mode --off-variation off --fallthrough control + """ + + async def _run_flags_edit() -> None: + patch: dict[str, Any] = {} + if name is not None: + patch["name"] = name + if description is not None: + patch["description"] = description + if off_variation is not None: + patch["off_variation"] = off_variation + if fallthrough is not None: + patch["fallthrough"] = fallthrough + if not patch: + err_console.print("[yellow]Nothing to update — provide at least one option.[/yellow]") + raise typer.Exit(1) + result = await make_client().patch_flag(key, patch) + console.print(f"[green]✓[/green] Flag [bold cyan]{result['key']}[/bold cyan] updated.") + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False, show_header=False) + tbl.add_column("Field", style="dim", no_wrap=True) + tbl.add_column("Value", style="bold") + for field in ("name", "description", "off_variation", "fallthrough"): + if field in patch: + val = result.get(field) + tbl.add_row(field, str(val) if val is not None else "—") + console.print(tbl) + + _run(_run_flags_edit) + + +@flags_app.command("variations") +def flags_variations(key: str = typer.Argument(..., help="Flag key")) -> None: + """List variations for a feature flag.""" + + async def _run_flags_variations() -> None: + flag = await make_client().get_flag(key) + variations = flag.get("variations") or [] + if not variations: + console.print(f"[dim]No variations for flag '{key}'.[/dim]") + return + tbl = Table(box=box.ROUNDED, show_header=True, header_style="bold") + tbl.add_column("Name", style="bold cyan", no_wrap=True) + tbl.add_column("Value", style="white") + tbl.add_column("Description", style="dim") + tbl.add_column("Role", style="dim") + off_var = flag.get("off_variation", "") + fallthrough = flag.get("fallthrough") + for v in variations: + vname = v.get("name", "") + role = "" + if vname == off_var: + role = "[slate]off[/slate]" + elif isinstance(fallthrough, str) and vname == fallthrough: + role = "[magenta]fallthrough[/magenta]" + tbl.add_row(vname, str(v.get("value", "")), v.get("description") or "—", role) + console.print(f"[bold cyan]{flag['key']}[/bold cyan] [dim]{flag.get('type', '')}[/dim]") + console.print(tbl) + + _run(_run_flags_variations) + + +@flags_app.command("targeting") +def flags_targeting(key: str = typer.Argument(..., help="Flag key")) -> None: + """Show targeting rules for a feature flag (read-only view).""" + + async def _run_flags_targeting() -> None: + flag = await make_client().get_flag(key) + rules = flag.get("rules") or [] + + off_var = flag.get("off_variation", "—") + ft = flag.get("fallthrough", "—") + console.print( + f"[bold cyan]{flag['key']}[/bold cyan]" + f" off=[cyan]{off_var}[/cyan]" + f" fallthrough=[cyan]{ft}[/cyan]" + ) + + if not rules: + console.print("[dim]No targeting rules.[/dim]") + return + + for i, rule in enumerate(rules): + desc = rule.get("description") or "" + variation = rule.get("variation") or "—" + clauses = rule.get("clauses") or [] + console.print( + f"\n [bold]Rule {i + 1}[/bold]" + + (f" — {desc}" if desc else "") + + f" → [green]{variation}[/green]" + ) + console.print(f" [dim]id: {rule.get('id', '')}[/dim]") + for clause in clauses: + attr = clause.get("attribute", "") + op = clause.get("operator", "") + vals = clause.get("values") or [] + negate = clause.get("negate", False) + neg_str = "[dim]NOT[/dim] " if negate else "" + vals_str = ", ".join(str(v) for v in vals) + console.print(f" {neg_str}[cyan]{attr}[/cyan] [dim]{op}[/dim] {vals_str}") + + _run(_run_flags_targeting) + + +@flags_app.command("add-rule") +def flags_add_rule( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to serve when rule matches" + ), + segment: str | None = typer.Option( + None, "--segment", "-s", help="Segment key (adds an in_segment clause)" + ), + attribute: str | None = typer.Option( + None, "--attribute", "-a", help="Attribute name for a custom clause" + ), + operator: str = typer.Option( + "is", "--operator", "-o", help="Operator (e.g. is, in_segment, contains)" + ), + values: str | None = typer.Option(None, "--values", help="Comma-separated clause values"), + description: str = typer.Option("", "--description", "-d", help="Optional rule description"), + negate: bool = typer.Option(False, "--negate", help="Negate the clause result"), +) -> None: + """Add a targeting rule to a feature flag. + + \b + Segment-based rule (most common): + shield flags add-rule my-flag --variation on --segment beta-users + + Custom attribute rule: + shield flags add-rule my-flag --variation on \ + --attribute plan --operator is --values pro,enterprise + """ + if segment is None and attribute is None: + console.print("[red]Error:[/red] provide --segment or --attribute.") + raise typer.Exit(1) + if segment is not None and attribute is not None: + console.print("[red]Error:[/red] --segment and --attribute are mutually exclusive.") + raise typer.Exit(1) + + async def _run_add_rule() -> None: + client = make_client() + flag = await client.get_flag(key) + rules = list(flag.get("rules") or []) + + if segment is not None: + clause = { + "attribute": "key", + "operator": "in_segment", + "values": [segment], + "negate": negate, + } + else: + raw_vals: list[Any] = [v.strip() for v in (values or "").split(",") if v.strip()] + clause = { + "attribute": attribute, + "operator": operator, + "values": raw_vals, + "negate": negate, + } + + import uuid as _uuid + + new_rule: dict[str, Any] = { + "id": str(_uuid.uuid4()), + "description": description, + "clauses": [clause], + "variation": variation, + } + rules.append(new_rule) + await client.patch_flag(key, {"rules": rules}) + clause_summary = ( + f"in_segment [cyan]{segment}[/cyan]" + if segment is not None + else f"[cyan]{attribute}[/cyan] [dim]{operator}[/dim] {values}" + ) + console.print( + f"[green]✓[/green] Rule added to [bold cyan]{key}[/bold cyan]: " + f"{clause_summary} → [green]{variation}[/green]" + ) + console.print(f" [dim]id: {new_rule['id']}[/dim]") + + _run(_run_add_rule) + + +@flags_app.command("remove-rule") +def flags_remove_rule( + key: str = typer.Argument(..., help="Flag key"), + rule_id: str = typer.Option(..., "--rule-id", "-r", help="Rule ID to remove"), +) -> None: + """Remove a targeting rule from a feature flag by its ID. + + \b + shield flags remove-rule my-flag --rule-id + + Use 'shield flags targeting my-flag' to list rule IDs. + """ + + async def _run_remove_rule() -> None: + client = make_client() + flag = await client.get_flag(key) + rules = list(flag.get("rules") or []) + original_len = len(rules) + rules = [r for r in rules if r.get("id") != rule_id] + if len(rules) == original_len: + console.print(f"[red]Error:[/red] no rule with id '{rule_id}' found on flag '{key}'.") + raise typer.Exit(1) + await client.patch_flag(key, {"rules": rules}) + console.print( + f"[green]✓[/green] Rule [dim]{rule_id}[/dim] removed from [bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_rule) + + +# --------------------------------------------------------------------------- +# Prerequisites commands (shield flags add-prereq / remove-prereq) +# --------------------------------------------------------------------------- + + +@flags_app.command("add-prereq") +def flags_add_prereq( + key: str = typer.Argument(..., help="Flag key"), + prereq_flag: str = typer.Option(..., "--flag", "-f", help="Prerequisite flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation the prerequisite flag must return" + ), +) -> None: + """Add a prerequisite flag to a feature flag. + + \b + shield flags add-prereq my-flag --flag auth-flag --variation on + + The prerequisite flag must evaluate to the given variation before this + flag's rules run. If it doesn't, this flag serves its off_variation. + """ + + async def _run_add_prereq() -> None: + client = make_client() + flag = await client.get_flag(key) + if flag["key"] == prereq_flag: + console.print("[red]Error:[/red] a flag cannot be its own prerequisite.") + raise typer.Exit(1) + prereqs = list(flag.get("prerequisites") or []) + # avoid duplicates + for p in prereqs: + if p.get("flag_key") == prereq_flag: + console.print( + f"[yellow]Warning:[/yellow] prerequisite [cyan]{prereq_flag}[/cyan]" + " already exists. Updating variation." + ) + p["variation"] = variation + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" updated → must be [green]{variation}[/green]." + ) + return + prereqs.append({"flag_key": prereq_flag, "variation": variation}) + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" added to [bold cyan]{key}[/bold cyan]:" + f" must be [green]{variation}[/green]." + ) + + _run(_run_add_prereq) + + +@flags_app.command("remove-prereq") +def flags_remove_prereq( + key: str = typer.Argument(..., help="Flag key"), + prereq_flag: str = typer.Option(..., "--flag", "-f", help="Prerequisite flag key to remove"), +) -> None: + """Remove a prerequisite from a feature flag. + + \b + shield flags remove-prereq my-flag --flag auth-flag + """ + + async def _run_remove_prereq() -> None: + client = make_client() + flag = await client.get_flag(key) + prereqs = list(flag.get("prerequisites") or []) + original_len = len(prereqs) + prereqs = [p for p in prereqs if p.get("flag_key") != prereq_flag] + if len(prereqs) == original_len: + console.print( + f"[red]Error:[/red] prerequisite [cyan]{prereq_flag}[/cyan]" + f" not found on flag [cyan]{key}[/cyan]." + ) + raise typer.Exit(1) + await client.patch_flag(key, {"prerequisites": prereqs}) + console.print( + f"[green]✓[/green] Prerequisite [cyan]{prereq_flag}[/cyan]" + f" removed from [bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_prereq) + + +# --------------------------------------------------------------------------- +# Individual targets commands (shield flags target / untarget) +# --------------------------------------------------------------------------- + + +@flags_app.command("target") +def flags_target( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to serve to the context keys" + ), + context_keys: str = typer.Option( + ..., "--keys", "-k", help="Comma-separated context keys to pin" + ), +) -> None: + """Pin context keys to always receive a specific variation. + + \b + shield flags target my-flag --variation on --keys user_123,user_456 + + Individual targets are evaluated before rules — highest priority targeting. + """ + + async def _run_target() -> None: + client = make_client() + flag = await client.get_flag(key) + variation_names = [v["name"] for v in (flag.get("variations") or [])] + if variation not in variation_names: + console.print( + f"[red]Error:[/red] variation [cyan]{variation}[/cyan] not found." + f" Available: {', '.join(variation_names)}" + ) + raise typer.Exit(1) + new_keys = [k.strip() for k in context_keys.split(",") if k.strip()] + targets: dict[str, Any] = dict(flag.get("targets") or {}) + existing = list(targets.get(variation, [])) + added = [k for k in new_keys if k not in existing] + existing.extend(added) + targets[variation] = existing + await client.patch_flag(key, {"targets": targets}) + console.print( + f"[green]✓[/green] Added {len(added)} key(s)" + f" to [bold cyan]{key}[/bold cyan] → [green]{variation}[/green]." + ) + + _run(_run_target) + + +@flags_app.command("untarget") +def flags_untarget( + key: str = typer.Argument(..., help="Flag key"), + variation: str = typer.Option( + ..., "--variation", "-v", help="Variation to remove context keys from" + ), + context_keys: str = typer.Option( + ..., "--keys", "-k", help="Comma-separated context keys to unpin" + ), +) -> None: + """Remove context keys from individual targeting. + + \b + shield flags untarget my-flag --variation on --keys user_123 + """ + + async def _run_untarget() -> None: + client = make_client() + flag = await client.get_flag(key) + remove_keys = {k.strip() for k in context_keys.split(",") if k.strip()} + targets: dict[str, Any] = dict(flag.get("targets") or {}) + existing = list(targets.get(variation, [])) + if not existing: + console.print( + f"[yellow]Warning:[/yellow] no targets for variation [cyan]{variation}[/cyan]." + ) + raise typer.Exit(1) + updated = [k for k in existing if k not in remove_keys] + if updated: + targets[variation] = updated + else: + targets.pop(variation, None) + await client.patch_flag(key, {"targets": targets}) + removed = len(existing) - len(updated) + console.print( + f"[green]✓[/green] Removed {removed} key(s)" + f" from [bold cyan]{key}[/bold cyan] → [cyan]{variation}[/cyan]." + ) + + _run(_run_untarget) + + +# --------------------------------------------------------------------------- +# Segments command group (shield segments ...) +# --------------------------------------------------------------------------- + +segments_app = typer.Typer( + name="segments", + help="Manage targeting segments.", + no_args_is_help=True, +) +cli.add_typer(segments_app, name="segments") +cli.add_typer(segments_app, name="seg") + + +def _print_segments_table(segments: list[dict[str, Any]]) -> None: + tbl = Table(box=box.SIMPLE_HEAD, show_edge=False, pad_edge=False) + tbl.add_column("Key", style="bold cyan", no_wrap=True) + tbl.add_column("Name", style="white") + tbl.add_column("Included", style="green") + tbl.add_column("Excluded", style="red") + tbl.add_column("Rules", style="dim") + for s in segments: + included = s.get("included") or [] + excluded = s.get("excluded") or [] + rules = s.get("rules") or [] + tbl.add_row( + s.get("key", ""), + s.get("name", ""), + str(len(included)), + str(len(excluded)), + str(len(rules)), + ) + console.print(tbl) + + +@segments_app.command("list") +def segments_list() -> None: + """List all targeting segments.""" + + async def _run_segments_list() -> None: + segments = await make_client().list_segments() + if not segments: + console.print("[dim]No segments found.[/dim]") + return + _print_segments_table(segments) + console.print(f"[dim]{len(segments)} segment(s)[/dim]") + + _run(_run_segments_list) + + +@segments_app.command("get") +def segments_get(key: str = typer.Argument(..., help="Segment key")) -> None: + """Show details for a single segment.""" + + async def _run_segments_get() -> None: + seg = await make_client().get_segment(key) + console.print(f"[bold cyan]{seg['key']}[/bold cyan] [dim]{seg.get('name', '')}[/dim]") + included = seg.get("included") or [] + excluded = seg.get("excluded") or [] + rules = seg.get("rules") or [] + if included: + console.print( + f" Included ({len(included)}): [green]{', '.join(included[:10])}[/green]" + + (" …" if len(included) > 10 else "") + ) + if excluded: + console.print( + f" Excluded ({len(excluded)}): [red]{', '.join(excluded[:10])}[/red]" + + (" …" if len(excluded) > 10 else "") + ) + if rules: + console.print(f" Rules: [dim]{len(rules)} targeting rule(s)[/dim]") + if not included and not excluded and not rules: + console.print(" [dim](empty segment)[/dim]") + + _run(_run_segments_get) + + +@segments_app.command("create") +def segments_create( + key: str = typer.Argument(..., help="Unique segment key"), + name: str = typer.Option(..., "--name", "-n", help="Human-readable segment name"), + description: str = typer.Option("", "--description", "-d", help="Optional description"), +) -> None: + """Create a new targeting segment. + + \b + shield segments create beta_users --name "Beta Users" + """ + + async def _run_segments_create() -> None: + segment_data = { + "key": key, + "name": name, + "description": description, + "included": [], + "excluded": [], + "rules": [], + } + result = await make_client().create_segment(segment_data) + console.print(f"[green]✓[/green] Segment [bold cyan]{result['key']}[/bold cyan] created.") + + _run(_run_segments_create) + + +@segments_app.command("delete") +def segments_delete( + key: str = typer.Argument(..., help="Segment key"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Permanently delete a targeting segment.""" + if not yes: + typer.confirm(f"Delete segment '{key}'? This cannot be undone.", abort=True) + + async def _run_segments_delete() -> None: + result = await make_client().delete_segment(key) + console.print(f"[green]✓[/green] Segment [bold]{result['deleted']}[/bold] deleted.") + + _run(_run_segments_delete) + + +@segments_app.command("include") +def segments_include( + key: str = typer.Argument(..., help="Segment key"), + context_key: str = typer.Option( + ..., + "--context-key", + "-k", + help="Comma-separated context keys to add to the included list", + ), +) -> None: + """Add context keys to the segment's included list. + + \b + shield segments include beta_users --context-key user_123,user_456 + """ + + async def _run_segments_include() -> None: + new_keys = [k.strip() for k in context_key.split(",") if k.strip()] + seg = await make_client().get_segment(key) + included = list(seg.get("included") or []) + added = [k for k in new_keys if k not in included] + included.extend(added) + seg["included"] = included + await make_client().update_segment(key, seg) + console.print( + f"[green]✓[/green] Added {len(added)} key(s) to [bold cyan]{key}[/bold cyan] " + f"included list." + ) + + _run(_run_segments_include) + + +@segments_app.command("exclude") +def segments_exclude( + key: str = typer.Argument(..., help="Segment key"), + context_key: str = typer.Option( + ..., + "--context-key", + "-k", + help="Comma-separated context keys to add to the excluded list", + ), +) -> None: + """Add context keys to the segment's excluded list. + + \b + shield segments exclude beta_users --context-key user_789 + """ + + async def _run_segments_exclude() -> None: + new_keys = [k.strip() for k in context_key.split(",") if k.strip()] + seg = await make_client().get_segment(key) + excluded = list(seg.get("excluded") or []) + added = [k for k in new_keys if k not in excluded] + excluded.extend(added) + seg["excluded"] = excluded + await make_client().update_segment(key, seg) + console.print( + f"[green]✓[/green] Added {len(added)} key(s) to [bold cyan]{key}[/bold cyan] " + f"excluded list." + ) + + _run(_run_segments_exclude) + + +@segments_app.command("add-rule") +def segments_add_rule( + key: str = typer.Argument(..., help="Segment key"), + attribute: str = typer.Option( + ..., + "--attribute", + "-a", + help="Context attribute (e.g. plan, country)", + ), + operator: str = typer.Option( + "is", + "--operator", + "-o", + help="Operator (e.g. is, in, contains, in_segment)", + ), + values: str = typer.Option( + ..., + "--values", + "-V", + help="Comma-separated values to compare against", + ), + description: str = typer.Option("", "--description", "-d", help="Optional rule description"), + negate: bool = typer.Option(False, "--negate", help="Negate the clause result"), +) -> None: + """Add an attribute-based targeting rule to a segment. + + \b + Users matching ANY rule are included in the segment. + Multiple clauses within one rule are AND-ed together. + + \b + Examples: + shield segments add-rule beta_users --attribute plan --operator in --values pro,enterprise + shield segments add-rule beta_users --attribute country --operator is --values GB + shield segments add-rule beta_users --attribute email --operator ends_with \\ + --values @acme.com --description "Acme staff" + """ + + async def _run_add_rule() -> None: + import uuid as _uuid + + client = make_client() + seg = await client.get_segment(key) + rules = list(seg.get("rules") or []) + + # For segment operators the attribute defaults to "key" + attr = "key" if operator in ("in_segment", "not_in_segment") else attribute + raw_vals: list[Any] = [v.strip() for v in values.split(",") if v.strip()] + clause: dict[str, Any] = { + "attribute": attr, + "operator": operator, + "values": raw_vals, + "negate": negate, + } + new_rule: dict[str, Any] = { + "id": str(_uuid.uuid4()), + "clauses": [clause], + } + if description: + new_rule["description"] = description + rules.append(new_rule) + seg["rules"] = rules + await client.update_segment(key, seg) + + clause_summary = f"[cyan]{attr}[/cyan] [dim]{operator}[/dim] {values}" + console.print( + f"[green]✓[/green] Rule added to segment [bold cyan]{key}[/bold cyan]: {clause_summary}" + ) + console.print(f" [dim]id: {new_rule['id']}[/dim]") + + _run(_run_add_rule) + + +@segments_app.command("remove-rule") +def segments_remove_rule( + key: str = typer.Argument(..., help="Segment key"), + rule_id: str = typer.Option(..., "--rule-id", "-r", help="Rule ID to remove"), +) -> None: + """Remove a targeting rule from a segment by its ID. + + \b + shield segments remove-rule beta_users --rule-id + + Use 'shield segments get beta_users' to list rule IDs. + """ + + async def _run_remove_rule() -> None: + client = make_client() + seg = await client.get_segment(key) + rules = list(seg.get("rules") or []) + original_len = len(rules) + rules = [r for r in rules if r.get("id") != rule_id] + if len(rules) == original_len: + console.print( + f"[red]Error:[/red] no rule with id '{rule_id}' found on segment '{key}'." + ) + raise typer.Exit(1) + seg["rules"] = rules + await client.update_segment(key, seg) + console.print( + f"[green]✓[/green] Rule [dim]{rule_id}[/dim] removed from segment " + f"[bold cyan]{key}[/bold cyan]." + ) + + _run(_run_remove_rule) + + if __name__ == "__main__": cli() diff --git a/shield/core/backends/base.py b/shield/core/backends/base.py index 9b3db76..e26e5a6 100644 --- a/shield/core/backends/base.py +++ b/shield/core/backends/base.py @@ -279,3 +279,72 @@ async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: f"{type(self).__name__} does not support rate limit policy pub/sub." ) yield # make this a valid async generator + + # ------------------------------------------------------------------ + # Feature flag storage — concrete in-memory default implementations + # + # All backends get basic in-memory flag/segment storage for free. + # FileBackend and RedisBackend can override for persistence. + # Storage is lazily initialised on first use so existing backends + # that do not call super().__init__() are not affected. + # ------------------------------------------------------------------ + + def _flag_store(self) -> dict[str, Any]: + """Lazy per-instance dict for flag objects.""" + if not hasattr(self, "_flag_store_dict"): + object.__setattr__(self, "_flag_store_dict", {}) + return self._flag_store_dict # type: ignore[attr-defined, no-any-return] + + def _segment_store(self) -> dict[str, Any]: + """Lazy per-instance dict for segment objects.""" + if not hasattr(self, "_segment_store_dict"): + object.__setattr__(self, "_segment_store_dict", {}) + return self._segment_store_dict # type: ignore[attr-defined, no-any-return] + + async def load_all_flags(self) -> list[Any]: + """Return all stored feature flags. + + Returns a list of ``FeatureFlag`` objects. The default + implementation uses an in-memory store. Override for persistent + backends. + """ + return list(self._flag_store().values()) + + async def save_flag(self, flag: Any) -> None: + """Persist *flag* (a ``FeatureFlag`` instance) by its key. + + Default implementation keeps flags in memory. Override for + persistent backends. + """ + self._flag_store()[flag.key] = flag + + async def delete_flag(self, flag_key: str) -> None: + """Remove the flag with *flag_key* from storage. + + No-op if the flag does not exist. + """ + self._flag_store().pop(flag_key, None) + + async def load_all_segments(self) -> list[Any]: + """Return all stored segments. + + Returns a list of ``Segment`` objects. The default + implementation uses an in-memory store. Override for persistent + backends. + """ + return list(self._segment_store().values()) + + async def save_segment(self, segment: Any) -> None: + """Persist *segment* (a ``Segment`` instance) by its key. + + Default implementation keeps segments in memory. Override for + persistent backends. + """ + self._segment_store()[segment.key] = segment + + async def delete_segment(self, segment_key: str) -> None: + """Remove the segment with *segment_key* from storage. + + No-op if the segment does not exist. + """ + self._segment_store().pop(segment_key, None) diff --git a/shield/core/backends/memory.py b/shield/core/backends/memory.py index 857a69a..f1c354c 100644 --- a/shield/core/backends/memory.py +++ b/shield/core/backends/memory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib from collections import defaultdict, deque from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any @@ -69,7 +70,7 @@ async def set_state(self, path: str, state: RouteState) -> None: """Persist *state* for *path* and notify any subscribers.""" self._states[path] = state for queue in self._subscribers: - await queue.put(state) + queue.put_nowait(state) async def delete_state(self, path: str) -> None: """Remove state for *path*. No-op if not registered.""" @@ -120,7 +121,8 @@ async def subscribe(self) -> AsyncIterator[RouteState]: state = await queue.get() yield state finally: - self._subscribers.remove(queue) + with contextlib.suppress(ValueError): + self._subscribers.remove(queue) async def write_rate_limit_hit(self, hit: RateLimitHit) -> None: """Append a rate limit hit record, evicting the oldest when the cap is reached.""" @@ -155,7 +157,7 @@ async def set_rate_limit_policy( self._rl_policies[key] = policy_data event: dict[str, Any] = {"action": "set", "key": key, "policy": policy_data} for q in self._rl_policy_subscribers: - await q.put(event) + q.put_nowait(event) async def get_rate_limit_policies(self) -> list[dict[str, Any]]: """Return all persisted rate limit policies.""" @@ -167,7 +169,7 @@ async def delete_rate_limit_policy(self, path: str, method: str) -> None: self._rl_policies.pop(key, None) event: dict[str, Any] = {"action": "delete", "key": key} for q in self._rl_policy_subscribers: - await q.put(event) + q.put_nowait(event) async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: """Yield rate limit policy change events as they occur.""" @@ -177,4 +179,5 @@ async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: while True: yield await queue.get() finally: - self._rl_policy_subscribers.remove(queue) + with contextlib.suppress(ValueError): + self._rl_policy_subscribers.remove(queue) diff --git a/shield/core/backends/server.py b/shield/core/backends/server.py index e231f57..2fa108c 100644 --- a/shield/core/backends/server.py +++ b/shield/core/backends/server.py @@ -96,6 +96,11 @@ def __init__( self._rl_policy_cache: dict[str, dict[str, Any]] = {} self._rl_policy_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] + # Local feature flag / segment cache (populated by SSE flag events). + self._flag_cache: dict[str, Any] = {} # key → FeatureFlag raw dict + self._segment_cache: dict[str, Any] = {} # key → Segment raw dict + self._flag_subscribers: list[asyncio.Queue[dict[str, Any]]] = [] + self._client: httpx.AsyncClient | None = None self._sse_task: asyncio.Task[None] | None = None @@ -368,6 +373,64 @@ async def _listen_sse(self) -> None: "ShieldServerBackend[%s]: RL policy deleted — %s", self._app_id, key ) + elif event_type == "flag_updated": + key = envelope.get("key", "") + flag_data = envelope.get("flag") + if key and flag_data is not None: + self._flag_cache[key] = flag_data + flag_event: dict[str, Any] = { + "type": "flag_updated", + "key": key, + "flag": flag_data, + } + for q in self._flag_subscribers: + q.put_nowait(flag_event) + logger.debug( + "ShieldServerBackend[%s]: flag cache updated — %s", + self._app_id, + key, + ) + + elif event_type == "flag_deleted": + key = envelope.get("key", "") + if key: + self._flag_cache.pop(key, None) + flag_del_event: dict[str, Any] = {"type": "flag_deleted", "key": key} + for q in self._flag_subscribers: + q.put_nowait(flag_del_event) + logger.debug( + "ShieldServerBackend[%s]: flag deleted — %s", self._app_id, key + ) + + elif event_type == "segment_updated": + key = envelope.get("key", "") + seg_data = envelope.get("segment") + if key and seg_data is not None: + self._segment_cache[key] = seg_data + seg_event: dict[str, Any] = { + "type": "segment_updated", + "key": key, + "segment": seg_data, + } + for q in self._flag_subscribers: + q.put_nowait(seg_event) + logger.debug( + "ShieldServerBackend[%s]: segment cache updated — %s", + self._app_id, + key, + ) + + elif event_type == "segment_deleted": + key = envelope.get("key", "") + if key: + self._segment_cache.pop(key, None) + seg_del_event: dict[str, Any] = {"type": "segment_deleted", "key": key} + for q in self._flag_subscribers: + q.put_nowait(seg_del_event) + logger.debug( + "ShieldServerBackend[%s]: segment deleted — %s", self._app_id, key + ) + else: # Legacy plain-RouteState payload (old server without typed envelopes). try: @@ -537,4 +600,36 @@ async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: while True: yield await queue.get() finally: - self._rl_policy_subscribers.remove(queue) + with contextlib.suppress(ValueError): + self._rl_policy_subscribers.remove(queue) + + async def subscribe_flag_changes(self) -> AsyncIterator[dict[str, Any]]: + """Yield feature flag / segment change events pushed via the SSE connection. + + Each yielded dict has one of these shapes:: + + {"type": "flag_updated", "key": "my-flag", "flag": {...}} + {"type": "flag_deleted", "key": "my-flag"} + {"type": "segment_updated", "key": "my-seg", "segment": {...}} + {"type": "segment_deleted", "key": "my-seg"} + """ + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._flag_subscribers.append(queue) + try: + while True: + yield await queue.get() + finally: + with contextlib.suppress(ValueError): + self._flag_subscribers.remove(queue) + + # ------------------------------------------------------------------ + # Feature flag storage — returns locally cached data fetched via SSE + # ------------------------------------------------------------------ + + async def load_all_flags(self) -> list[Any]: + """Return all feature flags cached from the Shield Server.""" + return list(self._flag_cache.values()) + + async def load_all_segments(self) -> list[Any]: + """Return all segments cached from the Shield Server.""" + return list(self._segment_cache.values()) diff --git a/shield/core/engine.py b/shield/core/engine.py index d1d9851..ead8155 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -284,6 +284,34 @@ def get_audit_log(self, path: str | None = None, limit: int = 100) -> list[Audit """Sync version of :meth:`ShieldEngine.get_audit_log`.""" return self._run(self._engine.get_audit_log(path=path, limit=limit)) + # ------------------------------------------------------------------ + # Feature flags + # ------------------------------------------------------------------ + + @property + def flag_client(self) -> Any: + """Return the synchronous flag client, or ``None`` if flags are not active. + + Call ``engine.use_openfeature()`` first to activate the flag system. + + Since OpenFeature evaluation is CPU-bound, this client does **not** + require a thread bridge — all methods are safe to call directly from + a ``def`` handler running in an anyio worker thread. + + Example:: + + @router.get("/checkout") + def checkout(request: Request): + enabled = engine.sync.flag_client.get_boolean_value( + "new_checkout", False, {"targeting_key": request.state.user_id} + ) + return checkout_v2() if enabled else checkout_v1() + """ + fc = self._engine._flag_client + if fc is None: + return None + return fc.sync + class ShieldEngine: """Central orchestrator — all route lifecycle logic flows through here. @@ -344,6 +372,10 @@ def __init__( self._global_rate_limit_policy: Any = None # GlobalRateLimitPolicy | None # Sync proxy — created once, reused on every engine.sync access. self.sync: _SyncProxy = _SyncProxy(self) + # Feature flags — lazily set by use_openfeature(). + self._flag_provider: Any = None # ShieldOpenFeatureProvider | None + self._flag_client: Any = None # ShieldFeatureClient | None + self._flag_scheduler: Any = None # FlagScheduler | None (set by use_openfeature) # ------------------------------------------------------------------ # Async context manager — calls backend lifecycle hooks @@ -407,6 +439,19 @@ async def start(self) -> None: self._run_rl_policy_listener(), name="shield-rl-policy-listener", ) + if self._flag_provider is not None: + # The OpenFeature SDK calls initialize() synchronously at + # set_provider() time. For async overrides the SDK silently + # discards the coroutine; engine.start() detects and awaits it. + # For sync initialize (including the base-class no-op) the SDK + # already ran it, so we skip the redundant call and go straight + # to warming the async backend cache. + if asyncio.iscoroutinefunction(type(self._flag_provider).initialize): + await self._flag_provider.initialize() + else: + await self._flag_provider._load_all() + if self._flag_scheduler is not None: + await self._flag_scheduler.start() async def stop(self) -> None: """Cancel background listener tasks and wait for them to finish. @@ -429,6 +474,157 @@ async def stop(self) -> None: with contextlib.suppress(asyncio.CancelledError): await self._rl_policy_listener_task self._rl_policy_listener_task = None + if self._flag_scheduler is not None: + await self._flag_scheduler.stop() + if self._flag_provider is not None: + if asyncio.iscoroutinefunction(type(self._flag_provider).shutdown): + await self._flag_provider.shutdown() + else: + self._flag_provider.shutdown() + + # ------------------------------------------------------------------ + # Feature flags — OpenFeature wiring + # ------------------------------------------------------------------ + + def use_openfeature( + self, + provider: Any = None, + hooks: list[Any] | None = None, + domain: str = "shield", + ) -> Any: + """Activate the feature flag system backed by this engine's backend. + + Parameters + ---------- + provider: + An OpenFeature-compliant provider to use. Defaults to + ``ShieldOpenFeatureProvider(self.backend)`` — the built-in + provider backed by the same backend as the engine. + hooks: + Additional OpenFeature hooks to register globally. Default + hooks (``LoggingHook``) are always added. + domain: + The OpenFeature domain name for the client. Defaults to + ``"shield"``. + + Returns + ------- + ShieldFeatureClient + The feature client ready for flag evaluations. + + Raises + ------ + ImportError + When ``api-shield[flags]`` is not installed. + """ + from shield.core.feature_flags._guard import _require_flags + + _require_flags() + + import openfeature.api as of_api + from openfeature.hook import Hook + + from shield.core.feature_flags.client import ShieldFeatureClient + from shield.core.feature_flags.hooks import LoggingHook + from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + + if provider is None: + provider = ShieldOpenFeatureProvider(self.backend) + + self._flag_provider = provider + + # Register the provider under the given domain (OpenFeature >=0.8 API). + try: + of_api.set_provider(provider, domain=domain) + except TypeError: + # Older openfeature-sdk versions without domain support. + of_api.set_provider(provider) + + from shield.core.feature_flags.hooks import MetricsHook + + metrics_hook = MetricsHook() + + # Build the default hook list and merge with any user-supplied hooks. + default_hooks: list[Hook] = [LoggingHook(), metrics_hook] + all_hooks = default_hooks + (hooks or []) + of_api.add_hooks(all_hooks) + + # Create and cache the client. + self._flag_client = ShieldFeatureClient(domain=domain) + + # Create the scheduler (start() is called later in engine.start()). + from shield.core.feature_flags.scheduler import FlagScheduler + + self._flag_scheduler = FlagScheduler(self) + + return self._flag_client + + @property + def flag_client(self) -> Any: + """Return the active ``ShieldFeatureClient``, or ``None`` if not configured. + + Call ``engine.use_openfeature()`` first to activate the flag system. + """ + return self._flag_client + + @property + def flag_scheduler(self) -> Any: + """Return the active ``FlagScheduler``, or ``None`` if not configured.""" + return self._flag_scheduler + + # ------------------------------------------------------------------ + # Feature flag CRUD — single chokepoint for flag + segment operations + # ------------------------------------------------------------------ + + async def list_flags(self) -> list[Any]: + """Return all feature flags from the provider cache (or backend).""" + if self._flag_provider is not None: + return list(self._flag_provider._flags.values()) + return await self.backend.load_all_flags() + + async def get_flag(self, key: str) -> Any: + """Return a single ``FeatureFlag`` by *key*, or ``None`` if not found.""" + if self._flag_provider is not None: + return self._flag_provider._flags.get(key) + flags = await self.backend.load_all_flags() + return next((f for f in flags if f.key == key), None) + + async def save_flag(self, flag: Any) -> None: + """Persist *flag* to the backend and update the provider cache.""" + await self.backend.save_flag(flag) + if self._flag_provider is not None: + self._flag_provider.upsert_flag(flag) + + async def delete_flag(self, key: str) -> None: + """Delete a flag by *key* from the backend and provider cache.""" + await self.backend.delete_flag(key) + if self._flag_provider is not None: + self._flag_provider.delete_flag(key) + + async def list_segments(self) -> list[Any]: + """Return all segments from the provider cache (or backend).""" + if self._flag_provider is not None: + return list(self._flag_provider._segments.values()) + return await self.backend.load_all_segments() + + async def get_segment(self, key: str) -> Any: + """Return a single ``Segment`` by *key*, or ``None`` if not found.""" + if self._flag_provider is not None: + return self._flag_provider._segments.get(key) + segments = await self.backend.load_all_segments() + return next((s for s in segments if s.key == key), None) + + async def save_segment(self, segment: Any) -> None: + """Persist *segment* to the backend and update the provider cache.""" + await self.backend.save_segment(segment) + if self._flag_provider is not None: + self._flag_provider.upsert_segment(segment) + + async def delete_segment(self, key: str) -> None: + """Delete a segment by *key* from the backend and provider cache.""" + await self.backend.delete_segment(key) + if self._flag_provider is not None: + self._flag_provider.delete_segment(key) async def _run_global_config_listener(self) -> None: """Background coroutine: invalidate the global config cache on remote changes. diff --git a/shield/core/feature_flags/__init__.py b/shield/core/feature_flags/__init__.py new file mode 100644 index 0000000..62789c1 --- /dev/null +++ b/shield/core/feature_flags/__init__.py @@ -0,0 +1,153 @@ +"""shield.core.feature_flags — OpenFeature-compliant feature flag system. + +This package requires the [flags] optional extra:: + + pip install api-shield[flags] + +Importing from this package when the extra is not installed raises an +``ImportError`` with clear installation instructions. + +All public symbols are re-exported under Shield-namespaced names. +``openfeature`` never appears in user-facing imports. + +Usage +----- +:: + + from shield.core.feature_flags import ( + EvaluationContext, + ShieldFeatureClient, + EvaluationReason, + ResolutionDetails, + ) + + ctx = EvaluationContext(key=user_id, attributes={"plan": "pro"}) + value = await flag_client.get_boolean_value("new_checkout", False, ctx) + +Custom provider (implements OpenFeature's AbstractProvider):: + + from shield.core.feature_flags import ShieldFlagProvider + + class MyProvider(ShieldFlagProvider): + ... + +Custom hook (implements OpenFeature's Hook interface):: + + from shield.core.feature_flags import ShieldHook +""" + +from __future__ import annotations + +# ── Guard: raise early with a helpful message if openfeature not installed ── +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +# ── OpenFeature ABC re-exports (Shield-namespaced) ────────────────────────── +# These are the extension points for users who want custom providers/hooks. +from openfeature.hook import Hook as ShieldHook +from openfeature.provider import AbstractProvider as ShieldFlagProvider + +# ── Client and provider re-exports ────────────────────────────────────────── +# Imported lazily here so the module graph stays clean. +# client.py and provider.py each call _require_flags() themselves. +from shield.core.feature_flags.client import ShieldFeatureClient as ShieldFeatureClient + +# ── Hook re-exports ───────────────────────────────────────────────────────── +from shield.core.feature_flags.hooks import ( + AuditHook as AuditHook, +) +from shield.core.feature_flags.hooks import ( + LoggingHook as LoggingHook, +) +from shield.core.feature_flags.hooks import ( + MetricsHook as MetricsHook, +) +from shield.core.feature_flags.hooks import ( + OpenTelemetryHook as OpenTelemetryHook, +) + +# ── Shield-native model re-exports ────────────────────────────────────────── +from shield.core.feature_flags.models import ( + EvaluationContext as EvaluationContext, +) +from shield.core.feature_flags.models import ( + EvaluationReason as EvaluationReason, +) +from shield.core.feature_flags.models import ( + FeatureFlag as FeatureFlag, +) +from shield.core.feature_flags.models import ( + FlagStatus as FlagStatus, +) +from shield.core.feature_flags.models import ( + FlagType as FlagType, +) +from shield.core.feature_flags.models import ( + FlagVariation as FlagVariation, +) +from shield.core.feature_flags.models import ( + Operator as Operator, +) +from shield.core.feature_flags.models import ( + Prerequisite as Prerequisite, +) +from shield.core.feature_flags.models import ( + ResolutionDetails as ResolutionDetails, +) +from shield.core.feature_flags.models import ( + RolloutVariation as RolloutVariation, +) +from shield.core.feature_flags.models import ( + RuleClause as RuleClause, +) +from shield.core.feature_flags.models import ( + ScheduledChange as ScheduledChange, +) +from shield.core.feature_flags.models import ( + ScheduledChangeAction as ScheduledChangeAction, +) +from shield.core.feature_flags.models import ( + Segment as Segment, +) +from shield.core.feature_flags.models import ( + SegmentRule as SegmentRule, +) +from shield.core.feature_flags.models import ( + TargetingRule as TargetingRule, +) +from shield.core.feature_flags.provider import ( + ShieldOpenFeatureProvider as ShieldOpenFeatureProvider, +) + +__all__ = [ + # Extension points + "ShieldFlagProvider", + "ShieldHook", + # Models + "EvaluationContext", + "EvaluationReason", + "FeatureFlag", + "FlagStatus", + "FlagType", + "FlagVariation", + "Operator", + "Prerequisite", + "ResolutionDetails", + "RolloutVariation", + "RuleClause", + "ScheduledChange", + "ScheduledChangeAction", + "Segment", + "SegmentRule", + "TargetingRule", + # Client + "ShieldFeatureClient", + # Provider + "ShieldOpenFeatureProvider", + # Hooks + "AuditHook", + "LoggingHook", + "MetricsHook", + "OpenTelemetryHook", +] diff --git a/shield/core/feature_flags/_context.py b/shield/core/feature_flags/_context.py new file mode 100644 index 0000000..88b7671 --- /dev/null +++ b/shield/core/feature_flags/_context.py @@ -0,0 +1,65 @@ +"""Context conversion helpers between Shield and OpenFeature types. + +Converts ``shield.core.feature_flags.models.EvaluationContext`` → +``openfeature.evaluation_context.EvaluationContext`` for provider dispatch, +and back again for the native provider's evaluator calls. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from shield.core.feature_flags.models import EvaluationContext as ShieldContext + + +def to_of_context(ctx: ShieldContext | None) -> object | None: + """Convert a Shield ``EvaluationContext`` to an OpenFeature one. + + Returns ``None`` when *ctx* is ``None`` (OpenFeature accepts ``None`` + to mean "use global context"). + + Also accepts plain ``dict`` for convenience in sync callers — the + ``targeting_key`` entry is mapped to the OpenFeature targeting key. + """ + if ctx is None: + return None + + from openfeature.evaluation_context import EvaluationContext as OFContext + + if isinstance(ctx, dict): + d = dict(ctx) + targeting_key = d.pop("targeting_key", "anonymous") + return OFContext(targeting_key=targeting_key, attributes=d) + + attrs = ctx.all_attributes() + # targeting_key is the OpenFeature equivalent of our ctx.key + targeting_key = attrs.pop("key", ctx.key) + return OFContext(targeting_key=targeting_key, attributes=attrs) + + +def from_of_context(of_ctx: object | None) -> ShieldContext: + """Convert an OpenFeature ``EvaluationContext`` to a Shield one. + + Used inside ``ShieldOpenFeatureProvider`` when the OpenFeature SDK + dispatches a resolution call so that ``FlagEvaluator`` receives the + right type. + """ + from shield.core.feature_flags.models import EvaluationContext as ShieldContext + + if of_ctx is None: + return ShieldContext(key="anonymous") + + # OpenFeature EvaluationContext has targeting_key + attributes + targeting_key = getattr(of_ctx, "targeting_key", None) or "anonymous" + attributes: dict[str, Any] = getattr(of_ctx, "attributes", {}) or {} + + return ShieldContext( + key=targeting_key, + kind=attributes.pop("kind", "user"), + email=attributes.pop("email", None), + ip=attributes.pop("ip", None), + country=attributes.pop("country", None), + app_version=attributes.pop("app_version", None), + attributes=attributes, + ) diff --git a/shield/core/feature_flags/_guard.py b/shield/core/feature_flags/_guard.py new file mode 100644 index 0000000..616c157 --- /dev/null +++ b/shield/core/feature_flags/_guard.py @@ -0,0 +1,26 @@ +"""Import guard for the feature flags optional dependency. + +Call ``_require_flags()`` at the top of any module that needs +``openfeature`` before attempting to import it. This produces a clear, +actionable error message instead of a bare ``ModuleNotFoundError``. + +``shield/core/feature_flags/models.py`` and ``evaluator.py`` are pure +Pydantic/stdlib and do **not** call this guard — they are importable +regardless of whether the [flags] extra is installed. Only the public +``shield.core.feature_flags`` namespace (``__init__.py``) and the +provider/client modules call this guard. +""" + +from __future__ import annotations + + +def _require_flags() -> None: + """Raise ``ImportError`` with install instructions if openfeature is missing.""" + try: + import openfeature # noqa: F401 + except ImportError: + raise ImportError( + "Feature flags require the [flags] extra.\n" + "Install with: pip install api-shield[flags]\n" + "Or: uv pip install 'api-shield[flags]'" + ) from None diff --git a/shield/core/feature_flags/client.py b/shield/core/feature_flags/client.py new file mode 100644 index 0000000..9918578 --- /dev/null +++ b/shield/core/feature_flags/client.py @@ -0,0 +1,171 @@ +"""ShieldFeatureClient — OpenFeature-backed flag evaluation API. + +Phase 2 implementation. Stub present so the package imports cleanly. +""" + +from __future__ import annotations + +from typing import Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + + +class _SyncShieldFeatureClient: + """Synchronous façade over :class:`ShieldFeatureClient`. + + Access via ``engine.sync.flag_client`` from sync route handlers. + FastAPI runs ``def`` handlers in anyio worker threads, which is exactly + the context this class is designed for. + + Because OpenFeature evaluation is CPU-bound (pure Python, no I/O), all + methods call the underlying OpenFeature client directly — no thread + bridge or event-loop interaction needed. + + Examples + -------- + :: + + @router.get("/checkout") + def checkout(request: Request): + enabled = engine.sync.flag_client.get_boolean_value( + "new_checkout", False, {"targeting_key": request.state.user_id} + ) + if enabled: + return checkout_v2() + return checkout_v1() + """ + + __slots__ = ("_of_client",) + + def __init__(self, of_client: object) -> None: + # ``of_client`` is the raw openfeature Client, not ShieldFeatureClient. + self._of_client = of_client + + def get_boolean_value( + self, + flag_key: str, + default: bool, + ctx: object | None = None, + ) -> bool: + """Evaluate a boolean flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_boolean_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_string_value( + self, + flag_key: str, + default: str, + ctx: object | None = None, + ) -> str: + """Evaluate a string flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_string_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_integer_value( + self, + flag_key: str, + default: int, + ctx: object | None = None, + ) -> int: + """Evaluate an integer flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_integer_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_float_value( + self, + flag_key: str, + default: float, + ctx: object | None = None, + ) -> float: + """Evaluate a float flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_float_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + def get_object_value( + self, + flag_key: str, + default: dict, # type: ignore[type-arg] + ctx: object | None = None, + ) -> dict: # type: ignore[type-arg] + """Evaluate a JSON/object flag synchronously.""" + from shield.core.feature_flags._context import to_of_context + + return self._of_client.get_object_value(flag_key, default, to_of_context(ctx)) # type: ignore[attr-defined, no-any-return, arg-type] + + +class ShieldFeatureClient: + """Thin wrapper around the OpenFeature client. + + Instantiated via ``engine.use_openfeature()``. + Do not construct directly. + """ + + def __init__(self, domain: str = "shield") -> None: + from openfeature import api + + self._client = api.get_client(domain) + self._domain = domain + + async def get_boolean_value( + self, + flag_key: str, + default: bool, + ctx: object | None = None, + ) -> bool: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_boolean_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_string_value( + self, + flag_key: str, + default: str, + ctx: object | None = None, + ) -> str: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_string_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_integer_value( + self, + flag_key: str, + default: int, + ctx: object | None = None, + ) -> int: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_integer_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_float_value( + self, + flag_key: str, + default: float, + ctx: object | None = None, + ) -> float: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_float_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type] + + async def get_object_value( + self, + flag_key: str, + default: dict[str, Any], + ctx: object | None = None, + ) -> dict[str, Any]: + from shield.core.feature_flags._context import to_of_context + + return self._client.get_object_value(flag_key, default, to_of_context(ctx)) # type: ignore[arg-type, return-value] + + @property + def sync(self) -> _SyncShieldFeatureClient: + """Return a synchronous façade for use in ``def`` (non-async) handlers. + + Prefer ``engine.sync.flag_client`` over accessing this directly. + """ + return _SyncShieldFeatureClient(self._client) diff --git a/shield/core/feature_flags/evaluator.py b/shield/core/feature_flags/evaluator.py new file mode 100644 index 0000000..f7d6456 --- /dev/null +++ b/shield/core/feature_flags/evaluator.py @@ -0,0 +1,443 @@ +"""Pure feature flag evaluation engine. + +No I/O, no async, no openfeature dependency. Fully unit-testable in +isolation by constructing ``FeatureFlag`` and ``EvaluationContext`` +objects directly. + +Evaluation order +---------------- +1. Flag disabled (``enabled=False``) → ``off_variation`` +2. Prerequisites — recursive, short-circuits on first failure +3. Individual targets — ``flag.targets[variation]`` contains ``ctx.key`` +4. Rules — top-to-bottom, first matching rule wins +5. Fallthrough — fixed variation or percentage rollout bucket + +Clause semantics +---------------- +- All clauses within a rule are AND-ed (all must match). +- Multiple values within one clause are OR-ed (any value must match). +- ``negate=True`` inverts the final result of the clause. + +Rollout bucketing +----------------- +SHA-1 hash of ``"{flag_key}:{ctx.kind}:{ctx.key}"`` modulo 100_000. +Deterministic and stable — the same context always lands in the same +bucket. Weights in ``RolloutVariation`` lists should sum to 100_000. +""" + +from __future__ import annotations + +import hashlib +import logging +import re +from typing import Any + +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + Operator, + ResolutionDetails, + RolloutVariation, + RuleClause, + Segment, + TargetingRule, +) + +logger = logging.getLogger(__name__) + +# Maximum prerequisite recursion depth to prevent accidental infinite loops. +_MAX_PREREQ_DEPTH = 10 + + +class FlagEvaluator: + """Evaluate feature flags against an evaluation context. + + Parameters + ---------- + segments: + Preloaded mapping of segment key → ``Segment``. Pass an empty + dict if no segments are defined. Updated in-place by the + provider on hot-reload. + + Examples + -------- + :: + + evaluator = FlagEvaluator(segments={"beta": beta_segment}) + result = evaluator.evaluate(flag, ctx, all_flags) + print(result.value, result.reason) + """ + + def __init__(self, segments: dict[str, Segment]) -> None: + self._segments = segments + + # ── Public interface ──────────────────────────────────────────────────── + + def evaluate( + self, + flag: FeatureFlag, + ctx: EvaluationContext, + all_flags: dict[str, FeatureFlag], + *, + _depth: int = 0, + ) -> ResolutionDetails: + """Evaluate *flag* for *ctx* and return a ``ResolutionDetails``. + + Parameters + ---------- + flag: + The flag to evaluate. + ctx: + Per-request evaluation context. + all_flags: + Full flag map — required for prerequisite resolution. + _depth: + Internal recursion counter. Do not pass from call sites. + """ + if _depth > _MAX_PREREQ_DEPTH: + logger.error( + "api-shield flags: prerequisite depth limit reached for flag '%s'. " + "Serving off_variation to prevent infinite recursion.", + flag.key, + ) + return self._off( + flag, + reason=EvaluationReason.ERROR, + error_message="Prerequisite depth limit exceeded", + ) + + # Step 1: global kill-switch + if not flag.enabled: + return self._off(flag, reason=EvaluationReason.OFF) + + # Step 2: prerequisites + for prereq in flag.prerequisites: + prereq_flag = all_flags.get(prereq.flag_key) + if prereq_flag is None: + logger.warning( + "api-shield flags: prerequisite flag '%s' not found " + "for flag '%s'. Serving off_variation.", + prereq.flag_key, + flag.key, + ) + return self._off( + flag, + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key=prereq.flag_key, + ) + prereq_result = self.evaluate(prereq_flag, ctx, all_flags, _depth=_depth + 1) + if prereq_result.variation != prereq.variation: + return self._off( + flag, + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key=prereq.flag_key, + ) + + # Step 3: individual targets + for variation_name, keys in flag.targets.items(): + if ctx.key in keys: + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.TARGET_MATCH, + ) + + # Step 4: targeting rules (top-to-bottom, first match wins) + for rule in flag.rules: + if self._rule_matches(rule, ctx): + variation_name = self._resolve_rule_variation(rule, ctx, flag) + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.RULE_MATCH, + rule_id=rule.id, + ) + + # Step 5: fallthrough (default rule) + variation_name = self._resolve_fallthrough(flag, ctx) + return ResolutionDetails( + value=flag.get_variation_value(variation_name), + variation=variation_name, + reason=EvaluationReason.FALLTHROUGH, + ) + + # ── Rule and clause matching ──────────────────────────────────────────── + + def _rule_matches(self, rule: TargetingRule, ctx: EvaluationContext) -> bool: + """Return ``True`` if ALL clauses in *rule* match *ctx* (AND logic).""" + return all(self._clause_matches(clause, ctx) for clause in rule.clauses) + + def _clause_matches(self, clause: RuleClause, ctx: EvaluationContext) -> bool: + """Evaluate a single clause against the context. + + Applies the operator, then inverts the result if ``negate=True``. + Returns ``False`` when the attribute is missing and the operator + requires a value (safe default — missing attribute → no match). + """ + attrs = ctx.all_attributes() + actual = attrs.get(clause.attribute) + result = self._apply_operator(clause.operator, actual, clause.values) + return not result if clause.negate else result + + def _apply_operator(self, op: Operator, actual: Any, values: list[Any]) -> bool: + """Apply *op* comparing *actual* against *values*. + + Multiple values use OR logic — returns ``True`` if any value matches. + Missing ``actual`` (``None``) returns ``False`` for all operators + except ``IS_NOT`` and ``NOT_IN``. + """ + # Segment operators delegate to _in_segment + if op == Operator.IN_SEGMENT: + return any(self._in_segment(actual, seg_key, _ctx=None) for seg_key in values) + if op == Operator.NOT_IN_SEGMENT: + return all(not self._in_segment(actual, seg_key, _ctx=None) for seg_key in values) + + if actual is None: + # Only IS_NOT and NOT_IN make sense with None + if op == Operator.IS_NOT: + return all(v is not None for v in values) + if op == Operator.NOT_IN: + return None not in values + return False + + match op: + # ── Equality ──────────────────────────────────────────────── + case Operator.IS: + return any(actual == v for v in values) + case Operator.IS_NOT: + return all(actual != v for v in values) + # ── String ────────────────────────────────────────────────── + case Operator.CONTAINS: + s = str(actual) + return any(str(v) in s for v in values) + case Operator.NOT_CONTAINS: + s = str(actual) + return all(str(v) not in s for v in values) + case Operator.STARTS_WITH: + s = str(actual) + return any(s.startswith(str(v)) for v in values) + case Operator.ENDS_WITH: + s = str(actual) + return any(s.endswith(str(v)) for v in values) + case Operator.MATCHES: + s = str(actual) + return any(_safe_regex(str(v), s) for v in values) + case Operator.NOT_MATCHES: + s = str(actual) + return all(not _safe_regex(str(v), s) for v in values) + # ── Numeric ───────────────────────────────────────────────── + case Operator.GT: + return _numeric_op(actual, values[0], lambda a, b: a > b) + case Operator.GTE: + return _numeric_op(actual, values[0], lambda a, b: a >= b) + case Operator.LT: + return _numeric_op(actual, values[0], lambda a, b: a < b) + case Operator.LTE: + return _numeric_op(actual, values[0], lambda a, b: a <= b) + # ── Date (ISO-8601 string lexicographic comparison) ────────── + case Operator.BEFORE: + return str(actual) < str(values[0]) + case Operator.AFTER: + return str(actual) > str(values[0]) + # ── Collection ────────────────────────────────────────────── + case Operator.IN: + return actual in values + case Operator.NOT_IN: + return actual not in values + # ── Semantic version ──────────────────────────────────────── + case Operator.SEMVER_EQ: + return _semver_op(actual, values[0], "eq") + case Operator.SEMVER_LT: + return _semver_op(actual, values[0], "lt") + case Operator.SEMVER_GT: + return _semver_op(actual, values[0], "gt") + case _: + logger.warning("api-shield flags: unknown operator '%s'", op) + return False + + # ── Segment evaluation ────────────────────────────────────────────────── + + def _in_segment( + self, + context_key: str | None, + segment_key: str, + *, + _ctx: EvaluationContext | None, + ) -> bool: + """Return ``True`` if *context_key* is a member of *segment_key*. + + Evaluation order: + 1. Key in ``excluded`` → False + 2. Key in ``included`` → True + 3. Any segment rule matches → True + 4. Otherwise → False + """ + if context_key is None: + return False + + seg = self._segments.get(segment_key) + if seg is None: + logger.warning( + "api-shield flags: segment '%s' not found — treating as empty.", + segment_key, + ) + return False + + if context_key in seg.excluded: + return False + if context_key in seg.included: + return True + + if _ctx is None: + # Segment rules need the full context — called from a clause + # that only passed the context key, not the full EvaluationContext. + # Without the full context we can't evaluate rules. + return False + + for rule in seg.rules: + if all(self._clause_matches(clause, _ctx) for clause in rule.clauses): + return True + + return False + + def _clause_matches_with_ctx(self, clause: RuleClause, ctx: EvaluationContext) -> bool: + """Clause match variant that passes *ctx* into segment evaluation.""" + if clause.operator in (Operator.IN_SEGMENT, Operator.NOT_IN_SEGMENT): + actual = ctx.key + if clause.operator == Operator.IN_SEGMENT: + result = any( + self._in_segment(actual, seg_key, _ctx=ctx) for seg_key in clause.values + ) + else: + result = all( + not self._in_segment(actual, seg_key, _ctx=ctx) for seg_key in clause.values + ) + return not result if clause.negate else result + return self._clause_matches(clause, ctx) + + def _rule_matches(self, rule: TargetingRule, ctx: EvaluationContext) -> bool: # type: ignore[no-redef] + """Return ``True`` if ALL clauses in *rule* match *ctx*. + + Uses ``_clause_matches_with_ctx`` so that segment operators receive + the full context for rule evaluation. + """ + return all(self._clause_matches_with_ctx(clause, ctx) for clause in rule.clauses) + + # ── Rollout and variation resolution ─────────────────────────────────── + + def _resolve_rule_variation( + self, rule: TargetingRule, ctx: EvaluationContext, flag: FeatureFlag + ) -> str: + """Return the variation name to serve for a matched rule.""" + if rule.variation is not None: + return rule.variation + if rule.rollout: + return self._bucket_rollout(rule.rollout, ctx, flag.key) + # Malformed rule — fall through to flag default + logger.warning( + "api-shield flags: rule '%s' on flag '%s' has neither variation " + "nor rollout — falling through to default.", + rule.id, + flag.key, + ) + return self._resolve_fallthrough(flag, ctx) + + def _resolve_fallthrough(self, flag: FeatureFlag, ctx: EvaluationContext) -> str: + """Return the variation name for the fallthrough (default) rule.""" + if isinstance(flag.fallthrough, str): + return flag.fallthrough + return self._bucket_rollout(flag.fallthrough, ctx, flag.key) + + @staticmethod + def _bucket_rollout( + rollout: list[RolloutVariation], + ctx: EvaluationContext, + flag_key: str, + ) -> str: + """Deterministic bucket assignment for percentage rollouts. + + Uses SHA-1 of ``"{flag_key}:{ctx.kind}:{ctx.key}"`` for stable, + consistent assignment. Bucket range is 0–99_999 (100_000 total) + matching the weight precision of ``RolloutVariation.weight``. + + Returns the last variation if weights don't sum to 100_000 (safe + fallback — never raises). + """ + seed = f"{flag_key}:{ctx.kind}:{ctx.key}" + bucket = int(hashlib.sha1(seed.encode()).hexdigest(), 16) % 100_000 + cumulative = 0 + for rv in rollout: + cumulative += rv.weight + if bucket < cumulative: + return rv.variation + return rollout[-1].variation + + # ── Helpers ───────────────────────────────────────────────────────────── + + @staticmethod + def _off( + flag: FeatureFlag, + *, + reason: EvaluationReason, + prerequisite_key: str | None = None, + error_message: str | None = None, + ) -> ResolutionDetails: + return ResolutionDetails( + value=flag.get_variation_value(flag.off_variation), + variation=flag.off_variation, + reason=reason, + prerequisite_key=prerequisite_key, + error_message=error_message, + ) + + +# ── Module-level helpers ────────────────────────────────────────────────────── + + +def _safe_regex(pattern: str, string: str) -> bool: + """Apply regex *pattern* to *string*, returning ``False`` on error.""" + try: + return bool(re.search(pattern, string)) + except re.error as exc: + logger.warning("api-shield flags: invalid regex '%s': %s", pattern, exc) + return False + + +def _numeric_op(actual: Any, threshold: Any, comparator: Any) -> bool: + """Apply a numeric comparison, returning ``False`` on type errors.""" + try: + return comparator(float(actual), float(threshold)) # type: ignore[no-any-return] + except (TypeError, ValueError): + return False + + +def _semver_op(actual: Any, threshold: Any, op: str) -> bool: + """Apply a semantic version comparison using ``packaging.version``. + + Falls back to ``False`` if ``packaging`` is not installed or the + version strings are malformed. + """ + try: + from packaging.version import Version + + a = Version(str(actual)) + b = Version(str(threshold)) + if op == "eq": + return a == b + if op == "lt": + return a < b + if op == "gt": + return a > b + except ImportError: + logger.warning( + "api-shield flags: semver operators require 'packaging'. " + "Install with: pip install api-shield[flags]" + ) + except Exception: # noqa: BLE001 + logger.warning( + "api-shield flags: semver comparison failed for values '%s' and '%s'.", + actual, + threshold, + ) + return False diff --git a/shield/core/feature_flags/hooks.py b/shield/core/feature_flags/hooks.py new file mode 100644 index 0000000..f1e6019 --- /dev/null +++ b/shield/core/feature_flags/hooks.py @@ -0,0 +1,168 @@ +"""Built-in OpenFeature hooks for api-shield. + +All hooks implement OpenFeature's ``Hook`` interface and are registered +via ``engine.use_openfeature(hooks=[...])``. + +Built-in hooks registered by default +------------------------------------- +``LoggingHook`` — logs every evaluation at DEBUG level. +``AuditHook`` — records non-trivial evaluations in ShieldEngine's audit log. +``MetricsHook`` — increments per-variation counters for dashboard stats. + +Optional hooks (user-registered) +--------------------------------- +``OpenTelemetryHook`` — sets ``feature_flag.*`` span attributes on the +current OpenTelemetry span. Requires ``opentelemetry-api`` to be installed. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType +from openfeature.hook import Hook, HookContext, HookHints + +logger = logging.getLogger(__name__) + + +class LoggingHook(Hook): + """Log every flag evaluation at DEBUG level. + + Automatically registered by ``engine.use_openfeature()``. + """ + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + logger.debug( + "api-shield flag eval: key=%s variant=%s reason=%s", + hook_context.flag_key, + details.variant, + details.reason, + ) + + def error( + self, + hook_context: HookContext, + exception: Exception, + hints: HookHints, + ) -> None: + logger.error( + "api-shield flag error: key=%s error=%s", + hook_context.flag_key, + exception, + ) + + +class AuditHook(Hook): + """Record flag evaluations in ShieldEngine's audit log. + + Only records evaluations with non-trivial reasons (RULE_MATCH, + TARGET_MATCH, PREREQUISITE_FAIL, ERROR) to avoid polluting the audit + log with FALLTHROUGH and DEFAULT entries. + + Automatically registered by ``engine.use_openfeature()``. + + Parameters + ---------- + engine: + The ``ShieldEngine`` instance to write audit entries to. + """ + + # Reasons worth recording + _RECORD_REASONS = frozenset(["TARGETING_MATCH", "DISABLED", "ERROR"]) + + def __init__(self, engine: Any) -> None: + self._engine = engine + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + if details.reason not in self._RECORD_REASONS: + return + # Fire-and-forget — audit writes are best-effort + import asyncio + import contextlib + + loop = asyncio.get_event_loop() + if loop.is_running(): + with contextlib.suppress(Exception): + loop.create_task( + self._engine.record_flag_evaluation(hook_context.flag_key, details) + ) + + +class MetricsHook(Hook): + """Increment per-variation evaluation counters. + + Parameters + ---------- + collector: + ``FlagMetricsCollector`` instance that stores the counters. + """ + + def __init__(self, collector: Any = None) -> None: + self._collector = collector + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + import asyncio + import contextlib + + ctx = hook_context.evaluation_context + targeting_key = getattr(ctx, "targeting_key", "anonymous") if ctx else "anonymous" + + record = { + "variation": details.variant or "unknown", + "reason": details.reason or "UNKNOWN", + "context_key": targeting_key, + } + + if self._collector is not None: + loop = asyncio.get_event_loop() + if loop.is_running(): + with contextlib.suppress(Exception): + loop.create_task(self._collector.record(hook_context.flag_key, record)) + + +class OpenTelemetryHook(Hook): + """Set ``feature_flag.*`` span attributes on the current OTel span. + + No-ops gracefully when ``opentelemetry-api`` is not installed. + Optional — register via ``engine.use_openfeature(hooks=[OpenTelemetryHook()])``. + """ + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + try: + from opentelemetry import trace # type: ignore[import-not-found] + + span = trace.get_current_span() + if span.is_recording(): + key = hook_context.flag_key + span.set_attribute(f"feature_flag.{key}.value", str(details.value)) + if details.variant: + span.set_attribute(f"feature_flag.{key}.variant", details.variant) + if details.reason: + span.set_attribute(f"feature_flag.{key}.reason", details.reason) + except ImportError: + pass # opentelemetry-api not installed — silently skip diff --git a/shield/core/feature_flags/models.py b/shield/core/feature_flags/models.py new file mode 100644 index 0000000..91f3a0b --- /dev/null +++ b/shield/core/feature_flags/models.py @@ -0,0 +1,590 @@ +"""Feature flag data models for api-shield. + +All models are pure Pydantic v2 with no dependency on ``openfeature``. +This module is importable even without the [flags] extra installed. + +Design notes +------------ +``EvaluationContext.all_attributes()`` merges named convenience fields +(email, ip, country, app_version) with the free-form ``attributes`` dict +so that rule clauses can reference any of them by name without callers +having to manually populate ``attributes`` for common fields. + +``RolloutVariation.weight`` is out of 100_000 (not 100) to allow +fine-grained rollouts like 0.1%, 33.33%, etc. — same precision as +LaunchDarkly. Weights in a rollout list should sum to 100_000. + +``FeatureFlag.targets`` maps variation name → list of context keys for +individual targeting. Evaluated before rules (highest priority after +prerequisites). + +``FeatureFlag.fallthrough`` accepts either a plain variation name +(``str``) for a fixed default, or a list of ``RolloutVariation`` for a +percentage-based default rule. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + +# ── Flag type ──────────────────────────────────────────────────────────────── + + +class FlagType(StrEnum): + """Value type of a feature flag's variations.""" + + BOOLEAN = "boolean" + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + JSON = "json" + + +# ── Variations ─────────────────────────────────────────────────────────────── + + +class FlagVariation(BaseModel): + """A single named variation of a feature flag. + + Parameters + ---------- + name: + Identifier used in rules, targets, fallthrough, and prerequisites. + E.g. ``"on"``, ``"off"``, ``"control"``, ``"variant_a"``. + value: + The actual value returned when this variation is served. + Must match the flag's ``type``. + description: + Optional human-readable note shown in the dashboard. + """ + + name: str + value: bool | str | int | float | dict[str, Any] | list[Any] + description: str = "" + + +class RolloutVariation(BaseModel): + """One bucket in a percentage rollout. + + Parameters + ---------- + variation: + References ``FlagVariation.name``. + weight: + Share of traffic (out of 100_000). All weights in a rollout + list should sum to 100_000. E.g. 25% = 25_000. + """ + + variation: str + weight: int = Field(ge=0, le=100_000) + + +# ── Targeting operators ────────────────────────────────────────────────────── + + +class Operator(StrEnum): + """All supported targeting rule operators. + + String operators + ---------------- + ``IS`` / ``IS_NOT`` — exact string equality. + ``CONTAINS`` / ``NOT_CONTAINS`` — substring match. + ``STARTS_WITH`` / ``ENDS_WITH`` — prefix / suffix match. + ``MATCHES`` / ``NOT_MATCHES`` — regex match (Python ``re`` module). + + Numeric operators + ----------------- + ``GT`` / ``GTE`` / ``LT`` / ``LTE`` — numeric comparisons. + + Date operators + -------------- + ``BEFORE`` / ``AFTER`` — ISO-8601 string comparisons (lexicographic). + + Collection operators + -------------------- + ``IN`` / ``NOT_IN`` — membership in a list of values. + + Segment operators + ----------------- + ``IN_SEGMENT`` / ``NOT_IN_SEGMENT`` — context is/isn't in a named segment. + + Semantic version operators + -------------------------- + ``SEMVER_EQ`` / ``SEMVER_LT`` / ``SEMVER_GT`` — PEP 440 / semver + comparison using ``packaging.version.Version``. + Requires ``packaging`` (installed with the [flags] extra). + """ + + # Equality + IS = "is" + IS_NOT = "is_not" + # String + CONTAINS = "contains" + NOT_CONTAINS = "not_contains" + STARTS_WITH = "starts_with" + ENDS_WITH = "ends_with" + MATCHES = "matches" + NOT_MATCHES = "not_matches" + # Numeric + GT = "gt" + GTE = "gte" + LT = "lt" + LTE = "lte" + # Date + BEFORE = "before" + AFTER = "after" + # Collection + IN = "in" + NOT_IN = "not_in" + # Segment + IN_SEGMENT = "in_segment" + NOT_IN_SEGMENT = "not_in_segment" + # Semantic version + SEMVER_EQ = "semver_eq" + SEMVER_LT = "semver_lt" + SEMVER_GT = "semver_gt" + + +# ── Rules ──────────────────────────────────────────────────────────────────── + + +class RuleClause(BaseModel): + """A single condition in a targeting rule. + + All clauses within a rule are AND-ed together. + Multiple values within one clause are OR-ed (any value must match). + + Parameters + ---------- + attribute: + Context attribute to inspect. E.g. ``"role"``, ``"plan"``, + ``"email"``, ``"country"``, ``"app_version"``. + operator: + Comparison operator to apply. + values: + One or more values to compare against. Multiple values use + OR logic — the clause passes if *any* value matches. + negate: + When ``True``, the result of the operator check is inverted. + """ + + attribute: str + operator: Operator + values: list[Any] + negate: bool = False + + +class TargetingRule(BaseModel): + """A complete targeting rule: all clauses match → serve a variation. + + Parameters + ---------- + id: + UUID4 identifier. Used for ordering, references, and scheduling. + description: + Human-readable label shown in the dashboard. + clauses: + List of ``RuleClause``. ALL must match (AND logic). + variation: + Fixed variation name to serve when rule matches. + Mutually exclusive with ``rollout``. + rollout: + Percentage rollout when rule matches. + Mutually exclusive with ``variation``. + track_events: + When ``True``, evaluation events for this rule are always + recorded regardless of global event sampling settings. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = Field(default_factory=list) + variation: str | None = None + rollout: list[RolloutVariation] | None = None + track_events: bool = False + + +# ── Prerequisites ───────────────────────────────────────────────────────────── + + +class Prerequisite(BaseModel): + """A prerequisite flag that must evaluate to a specific variation. + + Parameters + ---------- + flag_key: + Key of the prerequisite flag. + variation: + The variation the prerequisite flag must return. + If it returns any other variation, the dependent flag serves + its ``off_variation``. + """ + + flag_key: str + variation: str + + +# ── Segments ───────────────────────────────────────────────────────────────── + + +class SegmentRule(BaseModel): + """A rule within a segment definition. + + If all clauses match, the context is considered part of the segment. + Multiple segment rules are OR-ed (any matching rule → included). + + Parameters + ---------- + id: + UUID4 identifier for ordering and deletion. + description: + Optional human-readable label shown in the dashboard. + clauses: + List of ``RuleClause``. ALL must match (AND logic) for the rule + to match. + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + clauses: list[RuleClause] = Field(default_factory=list) + + +class Segment(BaseModel): + """A reusable group of contexts for flag targeting. + + Evaluation order: + 1. If ``context.key`` is in ``excluded`` → NOT in segment. + 2. If ``context.key`` is in ``included`` → IN segment. + 3. Evaluate ``rules`` top-to-bottom — first match → IN segment. + 4. No match → NOT in segment. + + Parameters + ---------- + key: + Unique identifier. Referenced by ``IN_SEGMENT`` clauses. + name: + Human-readable display name. + included: + Explicit context keys always included in this segment. + excluded: + Explicit context keys always excluded (overrides rules and included). + rules: + Targeting rules — any matching rule means the context is included. + tags: + Organisational labels for filtering in the dashboard. + """ + + key: str + name: str + description: str = "" + included: list[str] = Field(default_factory=list) + excluded: list[str] = Field(default_factory=list) + rules: list[SegmentRule] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +# ── Scheduled changes ───────────────────────────────────────────────────────── + + +class ScheduledChangeAction(StrEnum): + """Action to execute at a scheduled time.""" + + ENABLE = "enable" + DISABLE = "disable" + UPDATE_ROLLOUT = "update_rollout" + ADD_RULE = "add_rule" + DELETE_RULE = "delete_rule" + + +class ScheduledChange(BaseModel): + """A pending change to a flag scheduled for future execution. + + Parameters + ---------- + id: + UUID4 identifier. + execute_at: + UTC datetime when the change should fire. + action: + Which operation to apply to the flag. + payload: + Action-specific data. E.g. for ``UPDATE_ROLLOUT``:: + + {"variation": "on", "weight": 50_000} + + For ``ADD_RULE``: a serialised ``TargetingRule`` dict. + For ``DELETE_RULE``: ``{"rule_id": "..."}``. + created_by: + Actor who scheduled the change (username or ``"system"``). + """ + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + execute_at: datetime + action: ScheduledChangeAction + payload: dict[str, Any] = Field(default_factory=dict) + created_by: str = "system" + created_at: datetime = Field(default_factory=datetime.utcnow) + + +# ── Flag lifecycle status ───────────────────────────────────────────────────── + + +class FlagStatus(StrEnum): + """Computed lifecycle status of a feature flag. + + Derived from evaluation metrics and configuration — not stored. + """ + + NEW = "new" + """Created recently, never evaluated.""" + + ACTIVE = "active" + """Being evaluated or recently modified.""" + + LAUNCHED = "launched" + """Fully rolled out — single variation, stable, safe to clean up.""" + + INACTIVE = "inactive" + """Not evaluated in 7+ days.""" + + DEPRECATED = "deprecated" + """Marked deprecated by an operator. Still evaluated if enabled.""" + + ARCHIVED = "archived" + """Removed from active use. No longer evaluated.""" + + +# ── Full flag definition ────────────────────────────────────────────────────── + + +class FeatureFlag(BaseModel): + """Full definition of a feature flag. + + Stored in ``ShieldBackend`` alongside ``RouteState``. + Backend storage key convention: ``shield:flag:{key}``. + + Parameters + ---------- + key: + Unique identifier. Used in code: ``flags.get_boolean_value("my-flag", ...)``. + name: + Human-readable display name shown in the dashboard. + type: + Determines valid variation value types. + variations: + All possible flag values. Must contain at least two variations. + off_variation: + Variation served when ``enabled=False``. Must match a name in + ``variations``. + fallthrough: + Default rule when no targeting rule matches. Either a fixed + variation name (``str``) or a percentage rollout + (``list[RolloutVariation]`` summing to 100_000). + enabled: + Global kill-switch. When ``False``, all requests receive + ``off_variation`` regardless of targeting rules. + prerequisites: + Other flags that must evaluate to specific variations before this + flag's rules run. Evaluated recursively. Circular dependencies + are prevented at write time. + targets: + Individual targeting. Maps variation name → list of context keys + that always receive that variation. Evaluated after prerequisites, + before rules. + rules: + Targeting rules evaluated top-to-bottom. First match wins. + scheduled_changes: + Pending future mutations managed by ``FlagScheduler``. + temporary: + When ``True``, the flag hygiene system may mark it for removal + once it reaches ``LAUNCHED`` or ``INACTIVE`` status. + maintainer: + Username of the person responsible for this flag. + """ + + key: str + name: str + description: str = "" + type: FlagType + tags: list[str] = Field(default_factory=list) + + variations: list[FlagVariation] + off_variation: str + fallthrough: str | list[RolloutVariation] + + enabled: bool = True + prerequisites: list[Prerequisite] = Field(default_factory=list) + targets: dict[str, list[str]] = Field(default_factory=dict) + rules: list[TargetingRule] = Field(default_factory=list) + scheduled_changes: list[ScheduledChange] = Field(default_factory=list) + + status: FlagStatus = FlagStatus.ACTIVE + temporary: bool = True + maintainer: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + created_by: str = "system" + + def get_variation_value(self, name: str) -> Any: + """Return the value for the variation with the given name. + + Returns ``None`` if the variation name is not found — callers + should validate variation names at write time. + """ + for v in self.variations: + if v.name == name: + return v.value + return None + + def variation_names(self) -> list[str]: + """Return all variation names for this flag.""" + return [v.name for v in self.variations] + + +# ── Evaluation context ──────────────────────────────────────────────────────── + + +class EvaluationContext(BaseModel): + """Per-request context used for flag targeting. + + This is the primary object application code constructs and passes to + ``ShieldFeatureClient.get_*_value()``. + + Parameters + ---------- + key: + Required unique identifier for the entity being evaluated. + Typically ``user_id``, ``session_id``, or ``org_id``. Used for + individual targeting and deterministic rollout bucketing. + kind: + Context kind. Defaults to ``"user"``. Use ``"organization"``, + ``"device"``, or a custom string for non-user contexts. + email: + Convenience field — accessible in rules as ``"email"`` attribute. + ip: + Convenience field — accessible in rules as ``"ip"`` attribute. + country: + Convenience field — accessible in rules as ``"country"`` attribute. + app_version: + Convenience field — accessible in rules as ``"app_version"``. + Use semver operators for version-based targeting. + attributes: + Arbitrary additional attributes. Keys must be strings. + Values can be any JSON-serialisable type. + + Examples + -------- + Minimal context:: + + ctx = EvaluationContext(key=request.headers["x-user-id"]) + + Rich context:: + + ctx = EvaluationContext( + key=user.id, + kind="user", + email=user.email, + country=user.country, + app_version="2.3.1", + attributes={"plan": user.plan, "role": user.role}, + ) + """ + + key: str + kind: str = "user" + email: str | None = None + ip: str | None = None + country: str | None = None + app_version: str | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + def all_attributes(self) -> dict[str, Any]: + """Merge named convenience fields with ``attributes`` for rule evaluation. + + Named fields take lower priority than ``attributes`` — if the same + key appears in both, ``attributes`` wins. + + Returns + ------- + dict[str, Any] + Flat dict of all context attributes, including ``"key"`` and + ``"kind"`` as first-class attributes. + """ + base: dict[str, Any] = {"key": self.key, "kind": self.kind} + for field_name in ("email", "ip", "country", "app_version"): + val = getattr(self, field_name) + if val is not None: + base[field_name] = val + return {**base, **self.attributes} + + +# ── Resolution result ───────────────────────────────────────────────────────── + + +class EvaluationReason(StrEnum): + """Why a flag returned the value it did. + + Included in ``ResolutionDetails`` for every evaluation. + Used by the live events stream, audit hook, and eval debugger. + """ + + OFF = "OFF" + """Flag is globally disabled. ``off_variation`` was served.""" + + FALLTHROUGH = "FALLTHROUGH" + """No targeting rule matched. Default rule was served.""" + + TARGET_MATCH = "TARGET_MATCH" + """Context key was in the individual targets list.""" + + RULE_MATCH = "RULE_MATCH" + """A targeting rule matched. See ``rule_id``.""" + + PREREQUISITE_FAIL = "PREREQUISITE_FAIL" + """A prerequisite flag did not return the required variation. + See ``prerequisite_key``.""" + + ERROR = "ERROR" + """Provider or evaluation error. Default value was returned.""" + + DEFAULT = "DEFAULT" + """Flag not found in provider. SDK default was returned.""" + + +class ResolutionDetails(BaseModel): + """Full result of a feature flag evaluation. + + Application code usually only needs ``.value``. The extra fields + are used by hooks, the dashboard live stream, and the eval debugger. + + Parameters + ---------- + value: + The resolved flag value. + variation: + The variation name that was served. ``None`` on error/default. + reason: + Why this value was returned. + rule_id: + The ``TargetingRule.id`` that matched. Only set when + ``reason == RULE_MATCH``. + prerequisite_key: + The flag key of the failing prerequisite. Only set when + ``reason == PREREQUISITE_FAIL``. + error_message: + Human-readable error detail. Only set when ``reason == ERROR``. + """ + + value: Any + variation: str | None = None + reason: EvaluationReason + rule_id: str | None = None + prerequisite_key: str | None = None + error_message: str | None = None diff --git a/shield/core/feature_flags/provider.py b/shield/core/feature_flags/provider.py new file mode 100644 index 0000000..c63e6a4 --- /dev/null +++ b/shield/core/feature_flags/provider.py @@ -0,0 +1,199 @@ +"""ShieldOpenFeatureProvider — native OpenFeature provider backed by ShieldBackend. + +Phase 2 implementation. Stub present so the package imports cleanly. +Full implementation wires FlagEvaluator into the OpenFeature resolution API. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.provider import AbstractProvider +from openfeature.provider.metadata import Metadata + +from shield.core.feature_flags._context import from_of_context +from shield.core.feature_flags.evaluator import FlagEvaluator +from shield.core.feature_flags.models import ( + EvaluationReason, + FeatureFlag, + Segment, +) + +if TYPE_CHECKING: + from shield.core.backends.base import ShieldBackend + +logger = logging.getLogger(__name__) + +# Map Shield EvaluationReason → OpenFeature Reason string +_REASON_MAP: dict[EvaluationReason, str] = { + EvaluationReason.OFF: Reason.DISABLED, + EvaluationReason.FALLTHROUGH: Reason.DEFAULT, + EvaluationReason.TARGET_MATCH: Reason.TARGETING_MATCH, + EvaluationReason.RULE_MATCH: Reason.TARGETING_MATCH, + EvaluationReason.PREREQUISITE_FAIL: Reason.DISABLED, + EvaluationReason.ERROR: Reason.ERROR, + EvaluationReason.DEFAULT: Reason.DEFAULT, +} + + +class ShieldOpenFeatureProvider(AbstractProvider): + """OpenFeature-compliant provider backed by ``ShieldBackend``. + + Stores ``FeatureFlag`` and ``Segment`` objects in the same backend + as ``RouteState`` — no separate infrastructure required. + + Subscribes to backend pub/sub for instant hot-reload on flag changes. + Evaluates flags locally using ``FlagEvaluator`` — zero network calls + per evaluation. + + Parameters + ---------- + backend: + The ``ShieldBackend`` instance (Memory, File, or Redis). + Must be the same instance passed to ``ShieldEngine``. + """ + + def __init__(self, backend: ShieldBackend) -> None: + self._backend = backend + self._flags: dict[str, FeatureFlag] = {} + self._segments: dict[str, Segment] = {} + self._evaluator = FlagEvaluator(segments=self._segments) + + def get_metadata(self) -> Metadata: + return Metadata(name="shield") + + def get_provider_hooks(self) -> list[Any]: + return [] + + def initialize(self, evaluation_context: Any = None) -> None: + """No-op sync hook required by the OpenFeature SDK registry. + + The OpenFeature SDK calls this synchronously when ``set_provider()`` + is invoked. Actual async initialisation (loading flags from the + backend) is performed by ``engine.start()`` via ``_load_all()``. + """ + + def shutdown(self) -> None: + """No-op sync hook required by the OpenFeature SDK registry.""" + + async def _load_all(self) -> None: + """Load all flags and segments from backend into local cache.""" + try: + flags = await self._backend.load_all_flags() + self._flags = {f.key: f for f in flags} + segments = await self._backend.load_all_segments() + self._segments.update({s.key: s for s in segments}) + except AttributeError: + # Backend does not yet support flag storage (pre-Phase 3 backends). + # Operate with empty caches — all evaluations return defaults. + logger.debug( + "api-shield flags: backend does not support flag storage yet. " + "All flag evaluations will return defaults." + ) + + # ── OpenFeature resolution methods ────────────────────────────────────── + + def resolve_boolean_details( + self, flag_key: str, default_value: bool, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, bool) + + def resolve_string_details( + self, flag_key: str, default_value: str, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, str) + + def resolve_integer_details( + self, flag_key: str, default_value: int, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, int) + + def resolve_float_details( + self, flag_key: str, default_value: float, evaluation_context: Any = None + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, float) + + def resolve_object_details( # type: ignore[override] + self, + flag_key: str, + default_value: dict[str, Any], + evaluation_context: Any = None, + ) -> FlagResolutionDetails[Any]: + return self._resolve(flag_key, default_value, evaluation_context, dict) + + # ── Internal ──────────────────────────────────────────────────────────── + + def _resolve( + self, + flag_key: str, + default_value: Any, + of_ctx: Any, + expected_type: type, + ) -> FlagResolutionDetails[Any]: + flag = self._flags.get(flag_key) + if flag is None: + return FlagResolutionDetails( + value=default_value, + reason=Reason.DEFAULT, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag '{flag_key}' not found", + ) + + ctx = from_of_context(of_ctx) + try: + result = self._evaluator.evaluate(flag, ctx, self._flags) + except Exception as exc: # noqa: BLE001 + logger.exception("api-shield flags: evaluation error for '%s'", flag_key) + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message=str(exc), + ) + + value = result.value + # Type coercion — ensure returned value matches the expected type + if value is None: + value = default_value + else: + try: + value = expected_type(value) + except (TypeError, ValueError): + value = default_value + + flag_metadata: dict[str, int | float | str] = {} + if result.rule_id is not None: + flag_metadata["rule_id"] = result.rule_id + if result.prerequisite_key is not None: + flag_metadata["prerequisite_key"] = result.prerequisite_key + return FlagResolutionDetails( + value=value, + variant=result.variation, + reason=_REASON_MAP.get(result.reason, Reason.UNKNOWN), + flag_metadata=flag_metadata, + ) + + # ── Flag cache management (called by engine on flag CRUD) ──────────────── + + def upsert_flag(self, flag: FeatureFlag) -> None: + """Update or insert a flag in the local cache.""" + self._flags[flag.key] = flag + + def delete_flag(self, flag_key: str) -> None: + """Remove a flag from the local cache.""" + self._flags.pop(flag_key, None) + + def upsert_segment(self, segment: Segment) -> None: + """Update or insert a segment in the local cache.""" + self._segments[segment.key] = segment + + def delete_segment(self, segment_key: str) -> None: + """Remove a segment from the local cache.""" + self._segments.pop(segment_key, None) diff --git a/shield/core/feature_flags/scheduler.py b/shield/core/feature_flags/scheduler.py new file mode 100644 index 0000000..8905740 --- /dev/null +++ b/shield/core/feature_flags/scheduler.py @@ -0,0 +1,233 @@ +"""FlagScheduler — asyncio.Task-based scheduled flag change runner. + +Each :class:`ScheduledChange` on a :class:`FeatureFlag` gets one asyncio +task that sleeps until ``execute_at``, applies the action to the flag, then +removes the change from the flag's ``scheduled_changes`` list. + +On startup the scheduler scans all flags and re-creates tasks for any +pending changes whose ``execute_at`` is still in the future (restart +recovery). + +Supported :class:`~shield.core.feature_flags.models.ScheduledChangeAction`\\ s: + +* ``ENABLE`` — sets ``flag.enabled = True`` +* ``DISABLE`` — sets ``flag.enabled = False`` +* ``UPDATE_ROLLOUT`` — replaces ``flag.fallthrough`` with a new variation + name or rollout list from ``payload`` +* ``ADD_RULE`` — appends a :class:`TargetingRule` parsed from ``payload`` +* ``DELETE_RULE`` — removes the rule with ``payload["rule_id"]`` +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +import anyio + +if TYPE_CHECKING: + from shield.core.engine import ShieldEngine + +logger = logging.getLogger(__name__) + + +class FlagScheduler: + """Manages scheduled flag changes using ``asyncio.Task`` objects. + + Parameters + ---------- + engine: + The :class:`~shield.core.engine.ShieldEngine` used to read and + write flags. + """ + + def __init__(self, engine: ShieldEngine) -> None: + self._engine = engine + # (flag_key, change_id) → running task + self._tasks: dict[tuple[str, str], asyncio.Task[None]] = {} + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Restore pending scheduled changes from all flags. + + Called by ``ShieldEngine.start()`` when feature flags are enabled. + """ + try: + flags = await self._engine.list_flags() + except Exception: + logger.exception("FlagScheduler: failed to load flags on startup") + return + + now = datetime.now(UTC) + count = 0 + for flag in flags: + for change in list(flag.scheduled_changes): + execute_at = change.execute_at + if execute_at.tzinfo is None: + execute_at = execute_at.replace(tzinfo=UTC) + if execute_at > now: + self._create_task(flag.key, change) + count += 1 + if count: + logger.info("FlagScheduler: restored %d pending scheduled change(s)", count) + + async def stop(self) -> None: + """Cancel all pending scheduled change tasks.""" + for task in list(self._tasks.values()): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._tasks.clear() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def schedule(self, flag_key: str, change: Any) -> None: + """Register a new scheduled change task. + + If a task already exists for ``(flag_key, change.id)`` it is + cancelled and replaced. + + Parameters + ---------- + flag_key: + Key of the flag that owns the change. + change: + A :class:`~shield.core.feature_flags.models.ScheduledChange` + instance already appended to the flag's ``scheduled_changes`` + list and persisted to the backend. + """ + await self.cancel(flag_key, change.id) + self._create_task(flag_key, change) + + async def cancel(self, flag_key: str, change_id: str) -> None: + """Cancel the task for a specific scheduled change, if any.""" + task = self._tasks.pop((flag_key, change_id), None) + if task is not None: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + async def cancel_all_for_flag(self, flag_key: str) -> None: + """Cancel all pending tasks for *flag_key* (e.g. when a flag is deleted).""" + keys_to_cancel = [k for k in self._tasks if k[0] == flag_key] + for k in keys_to_cancel: + task = self._tasks.pop(k) + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + def list_pending(self) -> list[dict[str, str]]: + """Return a list of ``{"flag_key": ..., "change_id": ...}`` dicts.""" + return [{"flag_key": fk, "change_id": cid} for fk, cid in self._tasks] + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _create_task(self, flag_key: str, change: Any) -> asyncio.Task[None]: + task = asyncio.create_task( + self._run_change(flag_key, change), + name=f"shield-flag-scheduler:{flag_key}:{change.id}", + ) + self._tasks[(flag_key, change.id)] = task + task.add_done_callback(lambda t: self._tasks.pop((flag_key, change.id), None)) + return task + + async def _run_change(self, flag_key: str, change: Any) -> None: + """Sleep until ``execute_at``, then apply the change to the flag.""" + execute_at = change.execute_at + if execute_at.tzinfo is None: + execute_at = execute_at.replace(tzinfo=UTC) + + now = datetime.now(UTC) + delay = (execute_at - now).total_seconds() + if delay > 0: + try: + await anyio.sleep(delay) + except asyncio.CancelledError: + return + + logger.info( + "FlagScheduler: executing change %s (action=%s) on flag %r", + change.id, + change.action, + flag_key, + ) + try: + await self._apply_change(flag_key, change) + except Exception: + logger.exception( + "FlagScheduler: error applying change %s on flag %r", change.id, flag_key + ) + + async def _apply_change(self, flag_key: str, change: Any) -> None: + """Load the flag, mutate it, remove the change, and persist.""" + from shield.core.feature_flags.models import ScheduledChangeAction, TargetingRule + + flag = await self._engine.get_flag(flag_key) + if flag is None: + logger.warning( + "FlagScheduler: flag %r not found when applying change %s — skipping", + flag_key, + change.id, + ) + return + + action = change.action + payload = change.payload or {} + + if action == ScheduledChangeAction.ENABLE: + flag = flag.model_copy(update={"enabled": True}) + elif action == ScheduledChangeAction.DISABLE: + flag = flag.model_copy(update={"enabled": False}) + elif action == ScheduledChangeAction.UPDATE_ROLLOUT: + new_fallthrough = payload.get("variation") or payload.get("rollout") + if new_fallthrough is not None: + flag = flag.model_copy(update={"fallthrough": new_fallthrough}) + else: + logger.warning( + "FlagScheduler: UPDATE_ROLLOUT payload missing 'variation' for flag %r", + flag_key, + ) + elif action == ScheduledChangeAction.ADD_RULE: + try: + new_rule = TargetingRule.model_validate(payload) + updated_rules = list(flag.rules) + [new_rule] + flag = flag.model_copy(update={"rules": updated_rules}) + except Exception as exc: + logger.error( + "FlagScheduler: ADD_RULE payload invalid for flag %r: %s", flag_key, exc + ) + return + elif action == ScheduledChangeAction.DELETE_RULE: + rule_id = payload.get("rule_id") + updated_rules = [r for r in flag.rules if r.id != rule_id] + flag = flag.model_copy(update={"rules": updated_rules}) + else: + logger.warning("FlagScheduler: unknown action %r for flag %r", action, flag_key) + return + + # Remove the executed change from the flag's scheduled_changes list. + remaining = [c for c in flag.scheduled_changes if c.id != change.id] + flag = flag.model_copy(update={"scheduled_changes": remaining}) + await self._engine.save_flag(flag) + + logger.info( + "FlagScheduler: applied %s to flag %r (change %s)", + action, + flag_key, + change.id, + ) diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index b353ec9..fceb9cf 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -1040,6 +1040,8 @@ async def _generate() -> object: # Keepalive ping loop — runs when subscribe() is unsupported OR after # the subscription ends. Browsers keep the connection alive. while True: + if await request.is_disconnected(): + break yield ": keepalive\n\n" try: await anyio.sleep(15) @@ -1054,3 +1056,851 @@ async def _generate() -> object: "X-Accel-Buffering": "no", }, ) + + +# --------------------------------------------------------------------------- +# Feature flag dashboard pages +# --------------------------------------------------------------------------- + +_FLAG_TYPE_COLOURS = { + "boolean": "emerald", + "string": "blue", + "integer": "violet", + "float": "violet", + "json": "amber", +} + + +async def flags_page(request: Request) -> Response: + """GET /flags — feature flag list page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + flags = await engine.list_flags() + return tpl.TemplateResponse( + request, + "flags.html", + { + "prefix": prefix, + "flags": flags, + "active_tab": "flags", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flag_type_colours": _FLAG_TYPE_COLOURS, + "flags_enabled": True, + }, + ) + + +async def flags_rows_partial(request: Request) -> Response: + """GET /flags/rows — HTMX partial: flag table rows only. + + Supports ``?q=`` search query and ``?type=`` / ``?status=`` filters. + """ + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + flags = await engine.list_flags() + + q = request.query_params.get("q", "").lower().strip() + ftype = request.query_params.get("type", "").strip() + status_filter = request.query_params.get("status", "").strip() + + if q: + flags = [f for f in flags if q in f.key.lower() or q in (f.name or "").lower()] + if ftype: + flags = [f for f in flags if f.type.value == ftype] + if status_filter == "enabled": + flags = [f for f in flags if f.enabled] + elif status_filter == "disabled": + flags = [f for f in flags if not f.enabled] + + return tpl.TemplateResponse( + request, + "partials/flag_rows.html", + { + "prefix": prefix, + "flags": flags, + "flag_type_colours": _FLAG_TYPE_COLOURS, + }, + ) + + +async def flag_detail_page(request: Request) -> Response: + """GET /flags/{key} — single flag detail page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + segments = await engine.list_segments() + all_flags = await engine.list_flags() + return tpl.TemplateResponse( + request, + "flag_detail.html", + { + "prefix": prefix, + "flag": flag, + "segments": segments, + "all_flags": [f for f in all_flags if f.key != key], + "active_tab": "flags", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flag_type_colours": _FLAG_TYPE_COLOURS, + "flags_enabled": True, + }, + ) + + +async def flag_enable(request: Request) -> Response: + """POST /flags/{key}/enable — enable a flag; return updated row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("Flag not found", status_code=404) + flag = flag.model_copy(update={"enabled": True}) + await engine.save_flag(flag) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + ) + + +async def flag_disable(request: Request) -> Response: + """POST /flags/{key}/disable — disable a flag; return updated row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("Flag not found", status_code=404) + flag = flag.model_copy(update={"enabled": False}) + await engine.save_flag(flag) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + ) + + +async def flag_delete(request: Request) -> Response: + """DELETE /flags/{key} — delete a flag; return empty response (HTMX removes row).""" + engine = _engine(request) + key = request.path_params["key"] + await engine.delete_flag(key) + return HTMLResponse("") + + +async def modal_flag_create(request: Request) -> Response: + """GET /modal/flag/create — return create flag modal HTML.""" + tpl = _templates(request) + prefix = _prefix(request) + return tpl.TemplateResponse( + request, + "partials/modal_flag_create.html", + {"prefix": prefix}, + ) + + +async def flag_create_form(request: Request) -> Response: + """POST /flags/create — create a flag from form data; return new row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + form = await request.form() + key = str(form.get("key", "")).strip() + name = str(form.get("name", "")).strip() + ftype = str(form.get("type", "boolean")).strip() + + if not key or not name: + return HTMLResponse( + "

Key and name are required.

", + status_code=400, + ) + + from shield.core.feature_flags.models import FeatureFlag, FlagType, FlagVariation + + type_map = { + "boolean": ( + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "off", + "off", + ), + "string": ( + FlagType.STRING, + [ + FlagVariation(name="control", value="control"), + FlagVariation(name="treatment", value="treatment"), + ], + "control", + "control", + ), + "integer": ( + FlagType.INTEGER, + [FlagVariation(name="off", value=0), FlagVariation(name="on", value=1)], + "off", + "off", + ), + "float": ( + FlagType.FLOAT, + [FlagVariation(name="off", value=0.0), FlagVariation(name="on", value=1.0)], + "off", + "off", + ), + "json": ( + FlagType.JSON, + [FlagVariation(name="off", value={}), FlagVariation(name="on", value={})], + "off", + "off", + ), + } + if ftype not in type_map: + ftype = "boolean" + ft, variations, off_var, fallthrough = type_map[ftype] + + flag = FeatureFlag( + key=key, + name=name, + type=ft, + variations=variations, + off_variation=off_var, + fallthrough=fallthrough, + enabled=True, + ) + await engine.save_flag(flag) + return tpl.TemplateResponse( + request, + "partials/flag_row.html", + {"prefix": prefix, "flag": flag, "flag_type_colours": _FLAG_TYPE_COLOURS}, + headers={"HX-Trigger": "flagCreated"}, + ) + + +async def modal_flag_eval(request: Request) -> Response: + """GET /modal/flag/{key}/eval — return eval debugger modal HTML.""" + tpl = _templates(request) + prefix = _prefix(request) + key = request.path_params["key"] + engine = _engine(request) + flag = await engine.get_flag(key) + return tpl.TemplateResponse( + request, + "partials/modal_flag_eval.html", + {"prefix": prefix, "flag": flag, "key": key}, + ) + + +async def flag_eval_form(request: Request) -> Response: + """POST /flags/{key}/eval — evaluate flag from form data; return rich result partial.""" + import json as _json + + from shield.core.feature_flags.evaluator import FlagEvaluator + from shield.core.feature_flags.models import EvaluationContext + + tpl = _templates(request) + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + ctx_key = str(form.get("context_key", "anonymous")).strip() or "anonymous" + kind = str(form.get("kind", "user")).strip() or "user" + attrs_raw = str(form.get("attributes", "")).strip() + attributes: dict[str, str] = {} + for line in attrs_raw.splitlines(): + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + attributes[k.strip()] = v.strip() + + ctx = EvaluationContext(key=ctx_key, kind=kind, attributes=attributes) + all_flags_list = await engine.list_flags() + all_flags = {f.key: f for f in all_flags_list} + segments_list = await engine.list_segments() + segments = {s.key: s for s in segments_list} + evaluator = FlagEvaluator(segments=segments) + result = evaluator.evaluate(flag, ctx, all_flags) + + # Look up rule description for RULE_MATCH + rule_description = "" + if result.rule_id: + for rule in flag.rules: + if rule.id == result.rule_id: + rule_description = rule.description or "" + break + + # Serialize value as JSON for display (handles bool, dict, list, etc.) + try: + value_json = _json.dumps(result.value) + except (TypeError, ValueError): + value_json = str(result.value) + + trigger = _json.dumps( + { + "shieldEvalDone": { + "flagKey": key, + "value": result.value, + "reason": result.reason.value, + "error": bool(result.error_message), + "errorMessage": result.error_message or "", + } + } + ) + return tpl.TemplateResponse( + request, + "partials/flag_eval_result.html", + { + "result": result, + "rule_description": rule_description, + "value_json": value_json, + "ctx_key": ctx_key, + "ctx_kind": kind, + "ctx_attributes": attributes, + }, + headers={"HX-Trigger": trigger}, + ) + + +async def flag_settings_save(request: Request) -> Response: + """POST /flags/{key}/settings/save — update flag name and description.""" + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + form = await request.form() + name = str(form.get("name", flag.name)).strip() or flag.name + description = str(form.get("description", flag.description or "")).strip() + updated = flag.model_copy(update={"name": name, "description": description}) + await engine.save_flag(updated) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Settings saved
", + headers={"HX-Trigger": '{"flagSettingsSaved": true}'}, + ) + + +async def flag_variations_save(request: Request) -> Response: + """POST /flags/{key}/variations/save — replace flag variations.""" + import json as _json + import re as _re + + from shield.core.feature_flags.models import FlagType, FlagVariation + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + # Parse variations[N][field] pattern + indices: dict[int, dict[str, str]] = {} + for k, v in form.multi_items(): + m = _re.match(r"variations\[(\d+)\]\[(\w+)\]", k) + if m: + idx, field = int(m.group(1)), m.group(2) + indices.setdefault(idx, {})[field] = str(v) + + if not indices: + return HTMLResponse( + "

No variations provided.

", + status_code=400, + ) + + flag_type = flag.type + variations = [] + for i in sorted(indices.keys()): + entry = indices[i] + if entry.get("_deleted") == "1": + continue + name = entry.get("name", "").strip() + if not name: + return HTMLResponse( + f"

Variation {i} has no name.

", + status_code=400, + ) + raw_val = entry.get("value", "") + try: + parsed_val: bool | int | float | str | dict[str, Any] | list[Any] + if flag_type == FlagType.BOOLEAN: + parsed_val = raw_val.lower() in ("true", "1", "yes", "on") + elif flag_type == FlagType.INTEGER: + parsed_val = int(raw_val) + elif flag_type == FlagType.FLOAT: + parsed_val = float(raw_val) + elif flag_type == FlagType.JSON: + parsed_val = _json.loads(raw_val) if raw_val.strip() else {} + else: + parsed_val = raw_val + val = parsed_val + except Exception: + return HTMLResponse( + f"

Invalid value for variation '{name}'.

", + status_code=400, + ) + variations.append( + FlagVariation(name=name, value=val, description=entry.get("description", "") or "") + ) + + if len(variations) < 2: + return HTMLResponse( + "

At least two variations required.

", + status_code=400, + ) + + variation_names = {v.name for v in variations} + patch: dict[str, Any] = {"variations": variations} + # Fix off_variation if it no longer exists + if flag.off_variation not in variation_names: + patch["off_variation"] = variations[0].name + # Fix fallthrough if string and no longer valid + if isinstance(flag.fallthrough, str) and flag.fallthrough not in variation_names: + patch["fallthrough"] = variations[0].name + + updated = flag.model_copy(update=patch) + await engine.save_flag(updated) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Variations saved
", + headers={"HX-Trigger": '{"flagVariationsSaved": true}'}, + ) + + +async def flag_targeting_save(request: Request) -> Response: + """POST /flags/{key}/targeting/save — update off_variation, fallthrough, and rules.""" + import re as _re + import uuid as _uuid + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + variation_names = {v.name for v in flag.variations} + + patch: dict[str, Any] = {} + + # off_variation + off_var = str(form.get("off_variation", "")).strip() + if off_var: + if off_var not in variation_names: + return HTMLResponse( + f"

Unknown variation: {off_var}

", + status_code=400, + ) + patch["off_variation"] = off_var + + # fallthrough (only simple string form supported in dashboard) + fallthrough = str(form.get("fallthrough", "")).strip() + if fallthrough: + if fallthrough not in variation_names: + return HTMLResponse( + f"

Unknown variation: {fallthrough}

", + status_code=400, + ) + patch["fallthrough"] = fallthrough + + # rules — parse rules[N][field] and rules[N][clauses][M][field] + rule_data: dict[int, dict[str, Any]] = {} + for k, v in form.multi_items(): + m = _re.match(r"rules\[(\d+)\]\[clauses\]\[(\d+)\]\[(\w+)\]", k) + if m: + ri, ci, field = int(m.group(1)), int(m.group(2)), m.group(3) + rule_data.setdefault(ri, {}).setdefault("_clauses", {}).setdefault(ci, {})[field] = str( + v + ) + continue + m = _re.match(r"rules\[(\d+)\]\[(\w+)\]", k) + if m: + ri, field = int(m.group(1)), m.group(2) + rule_data.setdefault(ri, {})[field] = str(v) + + if rule_data: + from shield.core.feature_flags.models import Operator, RuleClause, TargetingRule + + rules = [] + for ri in sorted(rule_data.keys()): + rd = rule_data[ri] + if rd.get("_deleted") == "1": + continue + variation = rd.get("variation", "").strip() + if variation and variation not in variation_names: + return HTMLResponse( + f"

" + f"Rule {ri}: unknown variation '{variation}'

", + status_code=400, + ) + rule_id = rd.get("id", "").strip() or str(_uuid.uuid4()) + clauses = [] + for ci in sorted(rd.get("_clauses", {}).keys()): + cd = rd["_clauses"][ci] + if cd.get("_deleted") == "1": + continue + op_str = cd.get("operator", "is").strip() + try: + op = Operator(op_str) + except ValueError: + op = Operator.IS + # For segment operators the attribute field is hidden — default to "key" + is_seg_op = op in (Operator.IN_SEGMENT, Operator.NOT_IN_SEGMENT) + attr = cd.get("attribute", "").strip() or ("key" if is_seg_op else "") + if not attr: + continue + raw_values = cd.get("values", "") + values = [v.strip() for v in raw_values.split(",") if v.strip()] + negate = cd.get("negate", "false").lower() == "true" + clauses.append( + RuleClause(attribute=attr, operator=op, values=values, negate=negate) + ) + rules.append( + TargetingRule( + id=rule_id, + description=rd.get("description", "") or "", + clauses=clauses, + variation=variation or None, + ) + ) + patch["rules"] = rules + + if not patch: + return HTMLResponse( + "

Nothing to save.

", status_code=200 + ) + + updated = flag.model_copy(update=patch) + await engine.save_flag(updated) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Targeting saved
", + headers={"HX-Trigger": '{"flagTargetingSaved": true}'}, + ) + + +async def flag_prerequisites_save(request: Request) -> Response: + """POST /flags/{key}/prerequisites/save — update flag prerequisites.""" + import re as _re + + from shield.core.feature_flags.models import Prerequisite + + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + prereq_data: dict[int, dict[str, str]] = {} + for k, v in form.multi_items(): + m = _re.match(r"prereqs\[(\d+)\]\[(\w+)\]", k) + if m: + idx, field = int(m.group(1)), m.group(2) + prereq_data.setdefault(idx, {})[field] = str(v) + + prereqs = [] + for i in sorted(prereq_data.keys()): + entry = prereq_data[i] + flag_key = entry.get("flag_key", "").strip() + variation = entry.get("variation", "").strip() + if not flag_key or not variation: + continue + if flag_key == key: + return HTMLResponse( + "

A flag cannot be its own prerequisite.

", + status_code=400, + ) + prereqs.append(Prerequisite(flag_key=flag_key, variation=variation)) + + updated = flag.model_copy(update={"prerequisites": prereqs}) + await engine.save_flag(updated) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Prerequisites saved
", + headers={"HX-Trigger": '{"flagPrerequisitesSaved": true}'}, + ) + + +async def flag_targets_save(request: Request) -> Response: + """POST /flags/{key}/targets/save — update individual targets.""" + engine = _engine(request) + key = request.path_params["key"] + flag = await engine.get_flag(key) + if flag is None: + return HTMLResponse("

Flag not found.

", status_code=404) + + form = await request.form() + variation_names = {v.name for v in flag.variations} + targets: dict[str, list[str]] = {} + + for k, v in form.multi_items(): + if k.startswith("targets[") and k.endswith("]"): + variation_name = k[len("targets[") : -1] + if variation_name not in variation_names: + continue + keys = [line.strip() for line in str(v).splitlines() if line.strip()] + if keys: + targets[variation_name] = keys + + updated = flag.model_copy(update={"targets": targets}) + await engine.save_flag(updated) + _svg = ( + "" + "" + ) + return HTMLResponse( + "
" + + _svg + + "Targets saved
", + headers={"HX-Trigger": '{"flagTargetsSaved": true}'}, + ) + + +# --------------------------------------------------------------------------- +# Segment dashboard pages +# --------------------------------------------------------------------------- + + +async def segments_page(request: Request) -> Response: + """GET /segments — segment list page.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + segments = await engine.list_segments() + return tpl.TemplateResponse( + request, + "segments.html", + { + "prefix": prefix, + "segments": segments, + "active_tab": "segments", + "shield_actor": _actor(request), + "version": request.app.state.version, + "flags_enabled": True, + }, + ) + + +async def segments_rows_partial(request: Request) -> Response: + """GET /segments/rows — HTMX partial: segment table rows only.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + segments = await engine.list_segments() + q = request.query_params.get("q", "").lower().strip() + if q: + segments = [s for s in segments if q in s.key.lower() or q in (s.name or "").lower()] + return tpl.TemplateResponse( + request, + "partials/segment_rows.html", + {"prefix": prefix, "segments": segments}, + ) + + +async def modal_segment_view(request: Request) -> Response: + """GET /modal/segment/{key}/view — return segment info (read-only) modal.""" + tpl = _templates(request) + prefix = _prefix(request) + engine = _engine(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + return tpl.TemplateResponse( + request, + "partials/modal_segment_view.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def modal_segment_detail(request: Request) -> Response: + """GET /modal/segment/{key} — return segment detail/edit modal.""" + tpl = _templates(request) + prefix = _prefix(request) + engine = _engine(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + return tpl.TemplateResponse( + request, + "partials/modal_segment_detail.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def modal_segment_create(request: Request) -> Response: + """GET /modal/segment/create — return create segment modal.""" + tpl = _templates(request) + prefix = _prefix(request) + return tpl.TemplateResponse( + request, + "partials/modal_segment_create.html", + {"prefix": prefix}, + ) + + +async def segment_create_form(request: Request) -> Response: + """POST /segments/create — create segment from form; return new row partial.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + form = await request.form() + key = str(form.get("key", "")).strip() + name = str(form.get("name", "")).strip() + if not key or not name: + return HTMLResponse( + "

Key and name are required.

", + status_code=400, + ) + from shield.core.feature_flags.models import Segment + + segment = Segment(key=key, name=name) + await engine.save_segment(segment) + return tpl.TemplateResponse( + request, + "partials/segment_row.html", + {"prefix": prefix, "segment": segment}, + headers={"HX-Trigger": "segmentCreated"}, + ) + + +async def segment_delete(request: Request) -> Response: + """DELETE /segments/{key} — delete segment; return empty (HTMX removes row).""" + engine = _engine(request) + key = request.path_params["key"] + await engine.delete_segment(key) + return HTMLResponse("") + + +async def segment_save_form(request: Request) -> Response: + """POST /segments/{key}/save — save segment edits from detail modal.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + form = await request.form() + # Parse included/excluded as newline-separated keys + included_raw = str(form.get("included", "")).strip() + excluded_raw = str(form.get("excluded", "")).strip() + included = [k.strip() for k in included_raw.splitlines() if k.strip()] + excluded = [k.strip() for k in excluded_raw.splitlines() if k.strip()] + segment = segment.model_copy(update={"included": included, "excluded": excluded}) + await engine.save_segment(segment) + return tpl.TemplateResponse( + request, + "partials/segment_row.html", + {"prefix": prefix, "segment": segment}, + headers={"HX-Trigger": "segmentSaved"}, + ) + + +async def segment_rule_add(request: Request) -> Response: + """POST /segments/{key}/rules/add — add a targeting rule via the dashboard modal.""" + import uuid as _uuid + + from shield.core.feature_flags.models import Operator, RuleClause, SegmentRule + + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + form = await request.form() + description = str(form.get("description", "")).strip() + attribute = str(form.get("attribute", "")).strip() + operator_str = str(form.get("operator", "is")).strip() + values_raw = str(form.get("values", "")).strip() + negate = bool(form.get("negate")) + + # For segment operators the attribute is implicitly "key" + is_seg_op = operator_str in ("in_segment", "not_in_segment") + if is_seg_op: + attribute = "key" + + if not attribute or not values_raw: + return HTMLResponse( + "

Attribute and values are required.

", + status_code=400, + ) + + try: + op = Operator(operator_str) + except ValueError: + return HTMLResponse( + f"

Unknown operator: {operator_str}

", + status_code=400, + ) + + values: list[str] = [v.strip() for v in values_raw.split(",") if v.strip()] + clause = RuleClause(attribute=attribute, operator=op, values=values, negate=negate) + rule = SegmentRule(id=str(_uuid.uuid4()), description=description, clauses=[clause]) + + rules = list(segment.rules) + [rule] + segment = segment.model_copy(update={"rules": rules}) + await engine.save_segment(segment) + return tpl.TemplateResponse( + request, + "partials/segment_rules_section.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) + + +async def segment_rule_delete(request: Request) -> Response: + """DELETE /segments/{key}/rules/{rule_id} — remove a targeting rule.""" + tpl = _templates(request) + engine = _engine(request) + prefix = _prefix(request) + key = request.path_params["key"] + rule_id = request.path_params["rule_id"] + segment = await engine.get_segment(key) + if segment is None: + return HTMLResponse("

Segment not found.

", 404) + + rules = [r for r in segment.rules if r.id != rule_id] + segment = segment.model_copy(update={"rules": rules}) + await engine.save_segment(segment) + return tpl.TemplateResponse( + request, + "partials/segment_rules_section.html", + {"prefix": prefix, "segment": segment, "key": key}, + ) diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index 81f415b..73104e9 100644 --- a/shield/dashboard/static/shield.min.css +++ b/shield/dashboard/static/shield.min.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[22rem\]{max-width:22rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-300:focus{--tw-ring-color:var(--color-indigo-300)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-16{bottom:calc(var(--spacing) * 16)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mr-1{margin-right:calc(var(--spacing) * -1)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-\[1\.45rem\]{height:1.45rem}.h-\[1\.85rem\]{height:1.85rem}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-60{width:calc(var(--spacing) * 60)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[18rem\]{max-width:18rem}.max-w-\[22rem\]{max-width:22rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[380px\]{min-width:380px}.min-w-\[500px\]{min-width:500px}.min-w-\[520px\]{min-width:520px}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-orange-100{border-color:var(--color-orange-100)}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-800{border-color:var(--color-red-800)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-700{border-color:var(--color-slate-700)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-950{background-color:var(--color-red-950)}.bg-red-950\/60{background-color:#46080999}@supports (color:color-mix(in lab, red, red)){.bg-red-950\/60{background-color:color-mix(in oklab, var(--color-red-950) 60%, transparent)}}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/50{background-color:#f8fafc80}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/50{background-color:color-mix(in oklab, var(--color-slate-50) 50%, transparent)}}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-violet-700{background-color:var(--color-violet-700)}.bg-white{background-color:var(--color-white)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-400{color:var(--color-indigo-400)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-red-200{color:var(--color-red-200)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-400{color:var(--color-violet-400)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-orange-600\/20{--tw-ring-color:#f0510033}@supports (color:color-mix(in lab, red, red)){.ring-orange-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-orange-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-200{--tw-ring-color:var(--color-violet-200)}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}.group-open\/add\:rotate-45:is(:where(.group\/add):is([open],:popover-open,:open) *){rotate:45deg}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.first\:border-0:first-child{border-style:var(--tw-border-style);border-width:0}.focus-within\:border-violet-500:focus-within{border-color:var(--color-violet-500)}.focus-within\:ring-1:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-violet-500:focus-within{--tw-ring-color:var(--color-violet-500)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:border-violet-300:hover{border-color:var(--color-violet-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-600:hover{background-color:var(--color-violet-600)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-violet-800:hover{color:var(--color-violet-800)}.hover\:text-violet-900:hover{color:var(--color-violet-900)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-violet-400:focus{border-color:var(--color-violet-400)}.focus\:border-violet-500:focus{border-color:var(--color-violet-500)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-300:focus{--tw-ring-color:var(--color-slate-300)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-400:focus{--tw-ring-color:var(--color-violet-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-violet-600:focus{--tw-ring-color:var(--color-violet-600)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-white:focus{--tw-ring-offset-color:var(--color-white)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-violet-800:active{background-color:var(--color-violet-800)}@media (min-width:480px){.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:flex-1{flex:1}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}}@media (min-width:48rem){.md\:table-cell{display:table-cell}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:ml-auto{margin-left:auto}.lg\:hidden{display:none}.lg\:table-cell{display:table-cell}.lg\:w-64{width:calc(var(--spacing) * 64)}.lg\:shrink-0{flex-shrink:0}.lg\:translate-x-0{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:pl-60{padding-left:calc(var(--spacing) * 60)}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/shield/dashboard/templates/base.html b/shield/dashboard/templates/base.html index ce16f27..02aaadc 100644 --- a/shield/dashboard/templates/base.html +++ b/shield/dashboard/templates/base.html @@ -3,11 +3,17 @@ - Shield — Route Dashboard + Shield — Admin Dashboard + + + - - - -
-
- - -
- - - - - - - - - - - - - - - - - - api-shield - - + - -
- - - - {% if services %} - - {% endif %} - - - {% if shield_actor and shield_actor != 'anonymous' %} -
- - - - - - - -
- {% endif %} -
+
+ +
- - -
- {% block content %}{% endblock %} -
- - - - - - - - -
- - - - + + + + + + +
+ + + + + +
+ {% block content %}{% endblock %} +
+
+
+ + + + + + - + // URL-pattern success toasts for legacy route actions. + const url = (e.detail.xhr && e.detail.xhr.responseURL) || cfg.path || ''; + if (!url) return; + const msg = getMessage(url, verb); + if (msg) showToast(msg); + }); + })(); + + function applyService(svc) { + const url = new URL(window.location); + if (svc) url.searchParams.set('service', svc); + else url.searchParams.delete('service'); + window.location = url.toString(); + } + + // ── Mobile sidebar toggle ──────────────────────────────────── + function toggleSidebar() { + const s = document.getElementById('app-sidebar'); + const b = document.getElementById('sidebar-backdrop'); + const open = !s.classList.contains('-translate-x-full'); + s.classList.toggle('-translate-x-full', open); + s.classList.toggle('translate-x-0', !open); + b.classList.toggle('hidden', open); + } + + // ── Delete confirmation modal ──────────────────────────────── + function shieldConfirmDelete(opts) { + // opts: { label, key, url, targetId } + const _m = document.getElementById('shield-modal'); + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const safeKey = esc(opts.key || ''); + const safeUrl = esc(opts.url || ''); + const safeId = esc(opts.targetId || ''); + _m.innerHTML = ` +
+
+
+ + + +
+
+

Delete ${esc(opts.label || 'Item')}

+

This action cannot be undone.

+
+
+
+

+ Permanently delete + ${safeKey}? +

+
+
+ + +
+
`; + _m.showModal(); + } + function shieldExecDelete(url, targetId) { + htmx.ajax('DELETE', url, {target: '#' + targetId, swap: 'outerHTML'}); + document.getElementById('shield-modal').close(); + } + // Close
dropdowns when clicking outside + document.addEventListener('click', function(e) { + document.querySelectorAll('details[data-dropdown]').forEach(d => { + if (!d.contains(e.target)) d.removeAttribute('open'); + }); + }); + function shieldConfirmToggle(opts) { + const _m = document.getElementById('shield-modal'); + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const isEnable = opts.action === 'enable'; + const color = isEnable ? 'emerald' : 'amber'; + const label = isEnable ? 'Enable' : 'Disable'; + const bodyText = isEnable + ? 'will be enabled and start serving its on variation to users.' + : 'will be disabled and serve the off variation to all users.'; + const iconSvg = isEnable + ? '' + : ''; + const iconBgClass = isEnable ? 'shield-icon-enable' : 'shield-icon-disable'; + const btnClass = isEnable ? 'shield-btn-enable' : 'shield-btn-disable'; + _m.innerHTML = ` +
+
+
+ ${iconSvg} +
+
+

${label} flag?

+

+ Flag ${esc(opts.key)} ${bodyText} +

+
+
+
+ + +
+
`; + _m.showModal(); + } + function shieldExecToggle(url, targetId, swap) { + htmx.ajax('POST', url, {target: '#' + targetId, swap: swap}); + document.getElementById('shield-modal').close(); + } + diff --git a/shield/dashboard/templates/flag_detail.html b/shield/dashboard/templates/flag_detail.html new file mode 100644 index 0000000..5335c1c --- /dev/null +++ b/shield/dashboard/templates/flag_detail.html @@ -0,0 +1,773 @@ +{% extends "base.html" %} + +{% block content %} + +{% set _colour = flag_type_colours.get(flag.type.value, 'slate') %} + +{# ── Breadcrumb ────────────────────────────────────────────────────── #} + + +{# ── Header ────────────────────────────────────────────────────────── #} +
+

{{ flag.key }}

+ + {{ flag.type.value }} + + + {% if flag.enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + +
+ +{# ── Tabs ──────────────────────────────────────────────────────────── #} +
+
+ + + {# Overview #} + + + {# Targeting #} + + + {# Variations #} + + + {# Settings #} + +
+
+ +
+ + {# ── Overview panel ────────────────────────────────────────────── #} +
+
+ {# Details card #} +
+

Details

+
+
+
Type
+
+ + {{ flag.type.value }} + +
+
+
+
Status
+
+ {{ 'Enabled' if flag.enabled else 'Disabled' }} +
+
+
+
Off Variation
+
{{ flag.off_variation or '—' }}
+
+
+
Fallthrough
+
+ {% if flag.fallthrough is string %}{{ flag.fallthrough }} + {% elif flag.fallthrough is iterable %}rollout + {% else %}—{% endif %} +
+
+ {% if flag.description %} +
+
Description
+
{{ flag.description }}
+
+ {% endif %} +
+
+ + {# Counts card #} +
+

Counts

+
+
+
Rules
+
{{ flag.rules | length }}
+
+
+
Variations
+
{{ flag.variations | length }}
+
+
+
Prerequisites
+
{{ flag.prerequisites | length }}
+
+
+
Individual Targets
+
{{ flag.targets | length }}
+
+
+
+
+ + {# Variations summary table #} +
+
+

Variations

+
+ {% if flag.variations %} +
+ + + + + + + + + + {% for v in flag.variations %} + + + + + + {% endfor %} + +
NameValueDescription
{{ v.name }}{{ v.value }}{{ v.description or '—' }}
+
+ {% else %} +

No variations defined.

+ {% endif %} +
+
+ + {# ── Targeting panel ───────────────────────────────────────────── #} + + + {# ── Variations panel ──────────────────────────────────────────── #} + + + {# ── Settings panel ────────────────────────────────────────────── #} + + + +
+ + + +{% endblock %} diff --git a/shield/dashboard/templates/flags.html b/shield/dashboard/templates/flags.html new file mode 100644 index 0000000..70b8026 --- /dev/null +++ b/shield/dashboard/templates/flags.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Page header ───────────────────────────────────────────────────── #} +
+
+

Feature Flags

+

Manage and roll out features to your users

+
+ +
+ +{# ── Stats bar — always visible ────────────────────────────────────── #} +{% set total = flags | length %} +{% set enabled_count = flags | selectattr('enabled') | list | length %} +{% set disabled_count = total - enabled_count %} +
+
+ +
+
{{ total }}
+
Total
+
+
+
+ +
+
{{ enabled_count }}
+
Enabled
+
+
+
+ +
+
{{ disabled_count }}
+
Disabled
+
+
+
+ +{# ── Filters — only show if flags exist ────────────────────────────── #} +{% if flags %} +
+ {# Search — left on sm+ #} +
+ + + + +
+ {# Filters — right on sm+ #} +
+ + +
+
+{% endif %} + +{# ── Flags table — ALWAYS rendered so #flag-table-body always exists ─ #} +
+
+ + + + + + + + + + + + + {% include "partials/flag_rows.html" with context %} + +
KeyTypeStatusActions
+
+
+ +{% endblock %} diff --git a/shield/dashboard/templates/index.html b/shield/dashboard/templates/index.html index a9221b6..99adfa2 100644 --- a/shield/dashboard/templates/index.html +++ b/shield/dashboard/templates/index.html @@ -20,10 +20,10 @@

Routes

{{ states | length }} route{{ 's' if states | length != 1 else '' }} registered {% if n_maintenance > 0 %} - · {{ n_maintenance }} in maintenance + · {{ n_maintenance }} in maintenance {% endif %} {% if n_disabled > 0 %} - · {{ n_disabled }} disabled + · {{ n_disabled }} disabled {% endif %} {% if selected_service %} · Showing routes for {{ selected_service }} @@ -44,41 +44,41 @@

Routes

{# ── Status summary cards ─────────────────────────────────────────── #}
-
+
{{ n_active }}
Active
-
-
{{ n_maintenance }}
+
+
{{ n_maintenance }}
Maintenance
-
-
{{ n_disabled }}
+
+
{{ n_disabled }}
Disabled
-
-
{{ n_env_gated }}
+
+
{{ n_env_gated }}
Env Gated
-
+
{{ n_deprecated }}
Deprecated
{# ── Routes table ─────────────────────────────────────────────────── #} -
+
- - - - - - + + + + + + @@ -96,15 +96,15 @@

Routes

{% else %} {# ── Empty state ──────────────────────────────────────────────────── #} -
+
-
+

No routes registered

-

Routes appear here once the application starts with ShieldRouter or ShieldMiddleware.

+

Routes appear here once the application starts with ShieldRouter or ShieldMiddleware.

diff --git a/shield/dashboard/templates/login.html b/shield/dashboard/templates/login.html index abb9fa7..20bfe41 100644 --- a/shield/dashboard/templates/login.html +++ b/shield/dashboard/templates/login.html @@ -4,16 +4,18 @@ Shield — Sign in + + + - +
- - + @@ -33,8 +35,8 @@

api-shield

{% if error %} -
- +
+ api-shield {% endif %} -
+
-
-
+ +
diff --git a/shield/dashboard/templates/partials/flag_row.html b/shield/dashboard/templates/partials/flag_row.html new file mode 100644 index 0000000..bcbe1f4 --- /dev/null +++ b/shield/dashboard/templates/partials/flag_row.html @@ -0,0 +1,131 @@ +{% set _colour = flag_type_colours.get(flag.type.value, 'slate') %} +
+ + {# ── Key ─────────────────────────────────────────────────────────── #} + + + {# ── Type badge ───────────────────────────────────────────────────── #} + + + {# ── Status ───────────────────────────────────────────────────────── #} + + + {# ── Variations ───────────────────────────────────────────────────── #} + + + {# ── Fallthrough ──────────────────────────────────────────────────── #} + + + {# ── Actions ──────────────────────────────────────────────────────── #} + + diff --git a/shield/dashboard/templates/partials/flag_rows.html b/shield/dashboard/templates/partials/flag_rows.html new file mode 100644 index 0000000..5e96931 --- /dev/null +++ b/shield/dashboard/templates/partials/flag_rows.html @@ -0,0 +1,34 @@ +{% if flags %} + {% for flag in flags %} + {% include "partials/flag_row.html" with context %} + {% endfor %} +{% else %} + + + +{% endif %} diff --git a/shield/dashboard/templates/partials/modal_flag_create.html b/shield/dashboard/templates/partials/modal_flag_create.html new file mode 100644 index 0000000..e5400fd --- /dev/null +++ b/shield/dashboard/templates/partials/modal_flag_create.html @@ -0,0 +1,105 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Create Feature Flag

+

New flags start disabled — enable when ready to roll out.

+
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} + + +
+ {# Key #} +
+ + +

Lowercase letters, digits, hyphens, and underscores.

+
+ + {# Name #} +
+ + +
+ + {# Type #} +
+ + +
+
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_flag_eval.html b/shield/dashboard/templates/partials/modal_flag_eval.html new file mode 100644 index 0000000..442f096 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_flag_eval.html @@ -0,0 +1,112 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Evaluate Flag

+ + {% if flag %}{{ flag.key }}{% else %}{{ key }}{% endif %} + +
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} +
+ +
+ {# Context Key + Kind (side by side on sm) #} +
+
+ + +
+
+ + + + +
+
+ + {# Attributes #} +
+ + +

One key=value pair per line.

+
+
+ +
+ + +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_segment_create.html b/shield/dashboard/templates/partials/modal_segment_create.html new file mode 100644 index 0000000..0283249 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_create.html @@ -0,0 +1,86 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Create Segment

+

Group users to use in flag targeting rules.

+
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} +
+ +
+ {# Key #} +
+ + +

Lowercase letters, digits, hyphens, and underscores.

+
+ + {# Name #} +
+ + +
+
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_segment_detail.html b/shield/dashboard/templates/partials/modal_segment_detail.html new file mode 100644 index 0000000..113086e --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_detail.html @@ -0,0 +1,118 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Edit Segment

+ {{ segment.key }} +
+ +
+ + {# ── Form ────────────────────────────────────────────────────────── #} +
+ +
+ {# Key + Name (readonly, compact) #} +
+
+ +
+ {{ segment.key }} +
+
+
+ +
+ {{ segment.name }} +
+
+
+ + {# Included #} +
+
+ + {% set inc_count = segment.included | length %} + {% if inc_count > 0 %} + {{ inc_count }} user{{ 's' if inc_count != 1 else '' }} + {% endif %} +
+ +

One context key per line. Users in this list are always included.

+
+ + {# Excluded #} +
+
+ + {% set exc_count = segment.excluded | length %} + {% if exc_count > 0 %} + {{ exc_count }} user{{ 's' if exc_count != 1 else '' }} + {% endif %} +
+ +

One context key per line. Users in this list are always excluded.

+
+ +
+ + {# Rules section — managed independently via HTMX #} +
+ {% include "partials/segment_rules_section.html" with context %} +
+ +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/modal_segment_view.html b/shield/dashboard/templates/partials/modal_segment_view.html new file mode 100644 index 0000000..a799b3a --- /dev/null +++ b/shield/dashboard/templates/partials/modal_segment_view.html @@ -0,0 +1,151 @@ +
+ + {# ── Header ──────────────────────────────────────────────────────── #} +
+
+ + + +
+
+

{{ segment.name or segment.key }}

+ {{ segment.key }} +
+ +
+ + {# ── Stats row ────────────────────────────────────────────────────── #} +
+ + {{ segment.included | length }} + Included + + · + + {{ segment.excluded | length }} + Excluded + + · + + {{ segment.rules | length }} + Rules + +
+ +
+ + {# ── Included ─────────────────────────────────────────────────── #} +
+

+ Included + — always in segment +

+ {% if segment.included %} +
+
+ {% for k in segment.included %} + {{ k }} + {% endfor %} +
+
+ {% else %} +

No explicit inclusions.

+ {% endif %} +
+ + {# ── Excluded ─────────────────────────────────────────────────── #} +
+

+ Excluded + — always out of segment +

+ {% if segment.excluded %} +
+
+ {% for k in segment.excluded %} + {{ k }} + {% endfor %} +
+
+ {% else %} +

No explicit exclusions.

+ {% endif %} +
+ + {# ── Rules ────────────────────────────────────────────────────── #} +
+

+ Targeting Rules + — OR logic between rules, AND within clauses +

+ {% if segment.rules %} +
+ {% for rule in segment.rules %} +
+
+ Rule {{ loop.index }} + {{ rule.id[:8] }}… +
+ {% if rule.clauses %} +
+ {% for clause in rule.clauses %} +
+ {% if not loop.first %} + AND + {% else %} + + {% endif %} + {{ clause.attribute }} + {{ clause.operator.value }} + + {% for v in clause.values %} + {{ v }} + {% endfor %} + +
+ {% endfor %} +
+ {% else %} +

No clauses.

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No targeting rules defined.

+ {% endif %} +
+ +
+ + {# ── Footer ───────────────────────────────────────────────────────── #} +
+ + +
+ +
diff --git a/shield/dashboard/templates/partials/segment_row.html b/shield/dashboard/templates/partials/segment_row.html new file mode 100644 index 0000000..bb59941 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_row.html @@ -0,0 +1,96 @@ + + + {# ── Key ─────────────────────────────────────────────────────────── #} + + + {# ── Name ─────────────────────────────────────────────────────────── #} + + + {# ── Included ─────────────────────────────────────────────────────── #} + + + {# ── Excluded ─────────────────────────────────────────────────────── #} + + + {# ── Rules ─────────────────────────────────────────────────────────── #} + + + {# ── Actions ──────────────────────────────────────────────────────── #} + + diff --git a/shield/dashboard/templates/partials/segment_rows.html b/shield/dashboard/templates/partials/segment_rows.html new file mode 100644 index 0000000..3efe641 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_rows.html @@ -0,0 +1,34 @@ +{% if segments %} + {% for segment in segments %} + {% include "partials/segment_row.html" with context %} + {% endfor %} +{% else %} + + + +{% endif %} diff --git a/shield/dashboard/templates/partials/segment_rules_section.html b/shield/dashboard/templates/partials/segment_rules_section.html new file mode 100644 index 0000000..a08c730 --- /dev/null +++ b/shield/dashboard/templates/partials/segment_rules_section.html @@ -0,0 +1,172 @@ +{# ── Segment Rules Section ───────────────────────────────────────────────── + Rendered inside modal_segment_detail.html and returned by HTMX add/remove + rule endpoints. Expects: segment, key, prefix. +─────────────────────────────────────────────────────────────────────────── #} +
+ + {# ── Label ──────────────────────────────────────────────────────────── #} +
+ +
+

+ Users matching any rule are included in this segment. + Rules are evaluated after the explicit included / excluded lists. +

+ + {# ── Existing rules ──────────────────────────────────────────────────── #} + {% if segment.rules %} +
+ {% for rule in segment.rules %} +
+
+ {% if rule.description %} +

{{ rule.description }}

+ {% endif %} + {% for clause in rule.clauses %} + {% set is_seg_op = clause.operator.value in ['in_segment', 'not_in_segment'] %} +
+ {% if not loop.first %} + AND + {% endif %} + {% if clause.negate %} + NOT + {% endif %} + {% if not is_seg_op %} + {{ clause.attribute }} + {% endif %} + {{ clause.operator.value.replace('_', ' ') }} + {{ clause.values | join(', ') }} +
+ {% endfor %} +
+ +
+ {% endfor %} +
+ {% else %} +

No targeting rules yet.

+ {% endif %} + + {# ── Add Rule form ───────────────────────────────────────────────────── #} +
+ + + + + Add Rule + + +
+ + {# Description (optional) #} +
+ + +
+ + {# Clause: attribute + operator + values #} +
+ +
+ {# Attribute #} + + + {# Operator #} + + + {# Values #} + +
+

Separate multiple values with commas.

+
+ + {# Negate #} + + +
+ + +
+ +
+
+ + diff --git a/shield/dashboard/templates/segments.html b/shield/dashboard/templates/segments.html new file mode 100644 index 0000000..5376e03 --- /dev/null +++ b/shield/dashboard/templates/segments.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block content %} + +{# ── Page header ───────────────────────────────────────────────────── #} +
+
+

Segments

+

Group users for use in flag targeting rules

+
+ +
+ +{# ── Stats + Search row ─────────────────────────────────────────────── #} +{% set total = segments | length %} +{% set ns = namespace(included_total=0, excluded_total=0) %} +{% for s in segments %} +{% set ns.included_total = ns.included_total + (s.included | length) %} +{% set ns.excluded_total = ns.excluded_total + (s.excluded | length) %} +{% endfor %} +
+ {# Stats — 3 cols on all sizes, sits left on lg+ #} +
+
+ +
+
{{ total }}
+
Segments
+
+
+
+ +
+
{{ ns.included_total }}
+
Included
+
+
+
+ +
+
{{ ns.excluded_total }}
+
Excluded
+
+
+
+ + {# Search — below stats on mobile, right side on lg+ #} + {% if segments %} +
+ + + + +
+ {% endif %} +
+ +{# ── Segments table — ALWAYS rendered so #segment-table-body always exists ─ #} +
+
+
PathStatusActions
PathStatusActions
+ + {{ flag.key }} + + + + {{ flag.type.value }} + + + {% if flag.enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + + +
+ {# Eval #} + + + {# View #} + + + + + + View + + + {# Enable / Disable #} + {% if flag.enabled %} + + {% else %} + + {% endif %} + + {# Delete #} + +
+ +
+
+
+ + + +
+

No feature flags yet

+

+ Create your first feature flag to start controlling feature rollouts and experiments. +

+ +
+
+ + + + + {% set inc_count = segment.included | length %} + {% if inc_count > 0 %} + + {{ inc_count }} + + {% else %} + 0 + {% endif %} + + {% set exc_count = segment.excluded | length %} + {% if exc_count > 0 %} + + {{ exc_count }} + + {% else %} + 0 + {% endif %} + + +
+ {# Edit #} + + + {# Delete #} + +
+ +
+
+
+ + + +
+

No segments yet

+

+ Create segments to group users and use them in feature flag targeting rules. +

+ +
+
+ + + + + + + + + + + + {% include "partials/segment_rows.html" with context %} + +
KeyNameIncludedExcludedActions
+
+
+ +{% endblock %} diff --git a/shield/sdk/__init__.py b/shield/sdk/__init__.py index 1748a3e..6942f1b 100644 --- a/shield/sdk/__init__.py +++ b/shield/sdk/__init__.py @@ -129,6 +129,48 @@ def engine(self) -> ShieldEngine: """ return self._engine + def use_openfeature( + self, + hooks: list[Any] | None = None, + domain: str = "shield", + ) -> None: + """Enable OpenFeature feature-flag evaluation for this SDK client. + + Must be called **before** :meth:`attach`. + + Activates :class:`~shield.sdk.flag_provider.ShieldSDKFlagProvider` + which: + + * On startup fetches all flags/segments from the Shield Server via + ``GET /api/flags`` and ``GET /api/segments``. + * Stays current by listening to ``flag_updated``, ``flag_deleted``, + ``segment_updated``, and ``segment_deleted`` events on the + existing SSE connection — no extra network connections needed. + + Usage:: + + sdk = ShieldSDK(server_url="http://shield:9000", app_id="my-svc") + sdk.use_openfeature() + sdk.attach(app) + + # Evaluate anywhere via the engine's flag client: + value = await sdk.engine.flag_client.get_boolean_value( + "my-flag", default_value=False + ) + + Parameters + ---------- + hooks: + Optional list of OpenFeature :class:`Hook` objects to register + globally for this provider. + domain: + OpenFeature provider domain name (default ``"shield"``). + """ + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + provider = ShieldSDKFlagProvider(self._backend) + self._engine.use_openfeature(provider=provider, hooks=hooks, domain=domain) + def attach(self, app: FastAPI) -> None: """Wire shield middleware and lifecycle hooks into *app*. diff --git a/shield/sdk/flag_provider.py b/shield/sdk/flag_provider.py new file mode 100644 index 0000000..a9856af --- /dev/null +++ b/shield/sdk/flag_provider.py @@ -0,0 +1,174 @@ +"""ShieldSDKFlagProvider — OpenFeature provider for SDK clients. + +Syncs feature flags and segments from a remote Shield Server: + +1. On ``initialize()`` it fetches all flags/segments via REST + (``GET /api/flags`` and ``GET /api/segments``). +2. It then subscribes to the Shield Server's SSE stream + (``GET /api/sdk/events``) so any subsequent flag/segment mutations + made on the server side are reflected locally with no polling. + +Use ``ShieldSDK.use_openfeature()`` to activate this provider. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +from shield.core.feature_flags._guard import _require_flags + +_require_flags() + +from shield.core.feature_flags.models import FeatureFlag, Segment # noqa: E402 +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider # noqa: E402 + +if TYPE_CHECKING: + from shield.core.backends.server import ShieldServerBackend + +logger = logging.getLogger(__name__) + +__all__ = ["ShieldSDKFlagProvider"] + + +class ShieldSDKFlagProvider(ShieldOpenFeatureProvider): + """OpenFeature provider that hot-reloads flags from a Shield Server. + + Parameters + ---------- + backend: + The :class:`~shield.core.backends.server.ShieldServerBackend` + instance used by this SDK — the same one passed to + :class:`~shield.sdk.ShieldSDK`. + """ + + def __init__(self, backend: ShieldServerBackend) -> None: + super().__init__(backend) + self._server_backend = backend + self._watch_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # OpenFeature lifecycle + # ------------------------------------------------------------------ + + def initialize(self, evaluation_context: Any = None) -> None: + """Fetch flags from the server and start the SSE watch task. + + The OpenFeature SDK calls this synchronously; async work is + scheduled as asyncio tasks so no coroutine is left unawaited. + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no event loop — skip (unit-test or import-time context) + loop.create_task(self._async_initialize(), name="shield-sdk-flag-init") + + async def _async_initialize(self) -> None: + await self._fetch_from_server() + self._watch_task = asyncio.create_task(self._watch_sse(), name="shield-sdk-flag-watch") + + def shutdown(self) -> None: + """Cancel the SSE watcher task. + + The OpenFeature SDK calls this synchronously; the task is + cancelled without awaiting so no coroutine is left unawaited. + """ + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _fetch_from_server(self) -> None: + """Pull current flags and segments from the Shield Server REST API.""" + client = self._server_backend._client + if client is None: + logger.warning( + "ShieldSDKFlagProvider: HTTP client not ready — skipping initial flag fetch" + ) + return + + try: + resp = await client.get("/api/flags") + if resp.status_code == 200: + data = resp.json() + # The API returns either a list directly or {"flags": [...]} + items = data if isinstance(data, list) else data.get("flags", []) + for item in items: + try: + flag = FeatureFlag.model_validate(item) + self._flags[flag.key] = flag + # Also populate the backend's raw cache so + # load_all_flags() returns the right data. + self._server_backend._flag_cache[flag.key] = item + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + logger.warning("ShieldSDKFlagProvider: could not fetch flags from server") + + try: + resp = await client.get("/api/segments") + if resp.status_code == 200: + data = resp.json() + items = data if isinstance(data, list) else data.get("segments", []) + for item in items: + try: + seg = Segment.model_validate(item) + self._segments[seg.key] = seg + self._server_backend._segment_cache[seg.key] = item + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + logger.warning("ShieldSDKFlagProvider: could not fetch segments from server") + + logger.info( + "ShieldSDKFlagProvider: loaded %d flag(s), %d segment(s) from server", + len(self._flags), + len(self._segments), + ) + + async def _watch_sse(self) -> None: + """Subscribe to the backend's flag change queue and update local cache.""" + try: + async for event in self._server_backend.subscribe_flag_changes(): + etype = event.get("type") + + if etype == "flag_updated": + raw = event.get("flag") + if raw is not None: + try: + flag = FeatureFlag.model_validate(raw) + self._flags[flag.key] = flag + logger.debug("ShieldSDKFlagProvider: flag hot-reloaded — %s", flag.key) + except Exception: # noqa: BLE001 + pass + + elif etype == "flag_deleted": + key = event.get("key", "") + self._flags.pop(key, None) + logger.debug("ShieldSDKFlagProvider: flag removed — %s", key) + + elif etype == "segment_updated": + raw = event.get("segment") + if raw is not None: + try: + seg = Segment.model_validate(raw) + self._segments[seg.key] = seg + logger.debug( + "ShieldSDKFlagProvider: segment hot-reloaded — %s", seg.key + ) + except Exception: # noqa: BLE001 + pass + + elif etype == "segment_deleted": + key = event.get("key", "") + self._segments.pop(key, None) + logger.debug("ShieldSDKFlagProvider: segment removed — %s", key) + + except asyncio.CancelledError: + raise + except Exception: + logger.exception("ShieldSDKFlagProvider: SSE watch error") diff --git a/tests/admin/test_flag_api.py b/tests/admin/test_flag_api.py new file mode 100644 index 0000000..47d3926 --- /dev/null +++ b/tests/admin/test_flag_api.py @@ -0,0 +1,651 @@ +"""Tests for the feature flag + segment REST API endpoints in ShieldAdmin. + +All tests use an in-process ASGI transport — no real server needed. +The admin is mounted with ``enable_flags=True`` so flag routes are active. +""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + Segment, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def engine() -> ShieldEngine: + return ShieldEngine() + + +@pytest.fixture +def admin(engine: ShieldEngine): + """ShieldAdmin with flags enabled, no auth.""" + return ShieldAdmin(engine=engine, enable_flags=True) + + +@pytest.fixture +async def client(admin) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag_payload(key: str = "my_flag", enabled: bool = True) -> dict: + return { + "key": key, + "name": "My Flag", + "type": "boolean", + "variations": [ + {"name": "on", "value": True}, + {"name": "off", "value": False}, + ], + "off_variation": "off", + "fallthrough": "off", + "enabled": enabled, + } + + +def _segment_payload(key: str = "beta") -> dict: + return { + "key": key, + "name": "Beta Users", + "included": ["user_1", "user_2"], + "excluded": [], + "rules": [], + } + + +# --------------------------------------------------------------------------- +# Flag API — enable_flags=False (routes not mounted) +# --------------------------------------------------------------------------- + + +class TestFlagsNotMounted: + async def test_flag_routes_absent_when_disabled(self, engine): + admin_no_flags = ShieldAdmin(engine=engine, enable_flags=False) + async with AsyncClient( + transport=ASGITransport(app=admin_no_flags), + base_url="http://testserver", + ) as c: + resp = await c.get("/api/flags") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag API — list +# --------------------------------------------------------------------------- + + +class TestListFlags: + async def test_empty(self, client): + resp = await client.get("/api/flags") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_returns_saved_flag(self, client, engine): + + flag = FeatureFlag.model_validate(_bool_flag_payload()) + await engine.save_flag(flag) + + resp = await client.get("/api/flags") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["key"] == "my_flag" + + +# --------------------------------------------------------------------------- +# Flag API — create +# --------------------------------------------------------------------------- + + +class TestCreateFlag: + async def test_create_returns_201(self, client): + resp = await client.post("/api/flags", json=_bool_flag_payload()) + assert resp.status_code == 201 + assert resp.json()["key"] == "my_flag" + + async def test_create_persists_flag(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + flag = await engine.get_flag("my_flag") + assert flag is not None + assert flag.key == "my_flag" + + async def test_create_conflict_returns_409(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.post("/api/flags", json=_bool_flag_payload()) + assert resp.status_code == 409 + + async def test_create_invalid_body_returns_400(self, client): + resp = await client.post("/api/flags", json={"key": "bad"}) + assert resp.status_code == 400 + + async def test_create_string_flag(self, client): + payload = { + "key": "color_flag", + "name": "Color", + "type": "string", + "variations": [ + {"name": "blue", "value": "blue"}, + {"name": "red", "value": "red"}, + ], + "off_variation": "blue", + "fallthrough": "red", + } + resp = await client.post("/api/flags", json=payload) + assert resp.status_code == 201 + assert resp.json()["type"] == "string" + + async def test_create_with_targeting_rule(self, client): + payload = _bool_flag_payload() + payload["rules"] = [ + { + "clauses": [{"attribute": "role", "operator": "is", "values": ["admin"]}], + "variation": "on", + } + ] + resp = await client.post("/api/flags", json=payload) + assert resp.status_code == 201 + assert len(resp.json()["rules"]) == 1 + + +# --------------------------------------------------------------------------- +# Flag API — get +# --------------------------------------------------------------------------- + + +class TestGetFlag: + async def test_get_existing(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.get("/api/flags/my_flag") + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + async def test_get_missing_returns_404(self, client): + resp = await client.get("/api/flags/nonexistent") + assert resp.status_code == 404 + + async def test_get_returns_full_model(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.get("/api/flags/my_flag") + data = resp.json() + assert "variations" in data + assert "off_variation" in data + assert "fallthrough" in data + assert "enabled" in data + + +# --------------------------------------------------------------------------- +# Flag API — update +# --------------------------------------------------------------------------- + + +class TestUpdateFlag: + async def test_put_updates_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + updated = _bool_flag_payload() + updated["name"] = "Updated Name" + resp = await client.put("/api/flags/my_flag", json=updated) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Name" + + async def test_put_creates_if_missing(self, client): + # PUT is an upsert — creates if not exists + resp = await client.put("/api/flags/new_flag", json=_bool_flag_payload("new_flag")) + assert resp.status_code == 200 + assert resp.json()["key"] == "new_flag" + + async def test_put_key_mismatch_returns_400(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.put( + "/api/flags/my_flag", + json=_bool_flag_payload("other_key"), + ) + assert resp.status_code == 400 + + async def test_put_without_key_in_body_uses_url_key(self, client): + payload = _bool_flag_payload() + payload.pop("key") + resp = await client.put("/api/flags/my_flag", json=payload) + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + +# --------------------------------------------------------------------------- +# Flag API — enable / disable +# --------------------------------------------------------------------------- + + +class TestEnableDisableFlag: + async def test_enable_flag(self, client): + payload = _bool_flag_payload(enabled=False) + await client.post("/api/flags", json=payload) + + resp = await client.post("/api/flags/my_flag/enable") + assert resp.status_code == 200 + assert resp.json()["enabled"] is True + + async def test_disable_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload(enabled=True)) + + resp = await client.post("/api/flags/my_flag/disable") + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + async def test_enable_missing_returns_404(self, client): + resp = await client.post("/api/flags/nonexistent/enable") + assert resp.status_code == 404 + + async def test_disable_missing_returns_404(self, client): + resp = await client.post("/api/flags/nonexistent/disable") + assert resp.status_code == 404 + + async def test_enable_updates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_enable_cache") + await client.post("/api/flags", json=_bool_flag_payload(enabled=False)) + await client.post("/api/flags/my_flag/enable") + flag = engine._flag_provider._flags.get("my_flag") + assert flag is not None + assert flag.enabled is True + + +# --------------------------------------------------------------------------- +# Flag API — delete +# --------------------------------------------------------------------------- + + +class TestDeleteFlag: + async def test_delete_existing(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.delete("/api/flags/my_flag") + assert resp.status_code == 200 + assert resp.json()["deleted"] == "my_flag" + + async def test_delete_removes_from_list(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + resp = await client.get("/api/flags") + assert resp.json() == [] + + async def test_delete_missing_returns_404(self, client): + resp = await client.delete("/api/flags/nonexistent") + assert resp.status_code == 404 + + async def test_delete_updates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_delete_cache") + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + assert "my_flag" not in engine._flag_provider._flags + + +# --------------------------------------------------------------------------- +# Flag API — evaluate (debug endpoint) +# --------------------------------------------------------------------------- + + +class TestEvaluateFlag: + async def test_evaluate_fallthrough(self, client): + payload = _bool_flag_payload() + payload["fallthrough"] = "on" + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["value"] is True + assert data["reason"] == "FALLTHROUGH" + + async def test_evaluate_disabled_flag(self, client): + await client.post("/api/flags", json=_bool_flag_payload(enabled=False)) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 200 + assert resp.json()["reason"] == "OFF" + + async def test_evaluate_with_targeting_rule(self, client): + payload = _bool_flag_payload() + payload["rules"] = [ + { + "clauses": [{"attribute": "role", "operator": "is", "values": ["admin"]}], + "variation": "on", + } + ] + payload["fallthrough"] = "off" + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1", "attributes": {"role": "admin"}}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["value"] is True + assert data["reason"] == "RULE_MATCH" + + async def test_evaluate_missing_flag_returns_404(self, client): + resp = await client.post( + "/api/flags/nonexistent/evaluate", + json={"context": {"key": "user_1"}}, + ) + assert resp.status_code == 404 + + async def test_evaluate_no_context_uses_anonymous(self, client): + payload = _bool_flag_payload() + payload["fallthrough"] = "on" + await client.post("/api/flags", json=payload) + + resp = await client.post("/api/flags/my_flag/evaluate", json={}) + assert resp.status_code == 200 + + async def test_evaluate_returns_all_fields(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "u1"}}, + ) + data = resp.json() + assert "flag_key" in data + assert "value" in data + assert "variation" in data + assert "reason" in data + assert "rule_id" in data + assert "prerequisite_key" in data + + +# --------------------------------------------------------------------------- +# Segment API — list +# --------------------------------------------------------------------------- + + +class TestListSegments: + async def test_empty(self, client): + resp = await client.get("/api/segments") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_returns_saved_segment(self, client, engine): + seg = Segment.model_validate(_segment_payload()) + await engine.save_segment(seg) + + resp = await client.get("/api/segments") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["key"] == "beta" + + +# --------------------------------------------------------------------------- +# Segment API — create +# --------------------------------------------------------------------------- + + +class TestCreateSegment: + async def test_create_returns_201(self, client): + resp = await client.post("/api/segments", json=_segment_payload()) + assert resp.status_code == 201 + assert resp.json()["key"] == "beta" + + async def test_create_conflict_returns_409(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.post("/api/segments", json=_segment_payload()) + assert resp.status_code == 409 + + async def test_create_with_rules(self, client): + payload = _segment_payload() + payload["rules"] = [ + {"clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}]} + ] + resp = await client.post("/api/segments", json=payload) + assert resp.status_code == 201 + assert len(resp.json()["rules"]) == 1 + + async def test_create_invalid_body_returns_400(self, client): + resp = await client.post("/api/segments", json={"bad": "data"}) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Segment API — get +# --------------------------------------------------------------------------- + + +class TestGetSegment: + async def test_get_existing(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.get("/api/segments/beta") + assert resp.status_code == 200 + assert resp.json()["key"] == "beta" + + async def test_get_missing_returns_404(self, client): + resp = await client.get("/api/segments/nonexistent") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Segment API — update +# --------------------------------------------------------------------------- + + +class TestUpdateSegment: + async def test_put_updates_segment(self, client): + await client.post("/api/segments", json=_segment_payload()) + updated = _segment_payload() + updated["name"] = "Updated Beta" + resp = await client.put("/api/segments/beta", json=updated) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Beta" + + async def test_put_key_mismatch_returns_400(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.put( + "/api/segments/beta", + json=_segment_payload("other"), + ) + assert resp.status_code == 400 + + async def test_put_without_key_uses_url_key(self, client): + payload = _segment_payload() + payload.pop("key") + resp = await client.put("/api/segments/beta", json=payload) + assert resp.status_code == 200 + assert resp.json()["key"] == "beta" + + +# --------------------------------------------------------------------------- +# Segment API — delete +# --------------------------------------------------------------------------- + + +class TestDeleteSegment: + async def test_delete_existing(self, client): + await client.post("/api/segments", json=_segment_payload()) + resp = await client.delete("/api/segments/beta") + assert resp.status_code == 200 + assert resp.json()["deleted"] == "beta" + + async def test_delete_removes_from_list(self, client): + await client.post("/api/segments", json=_segment_payload()) + await client.delete("/api/segments/beta") + resp = await client.get("/api/segments") + assert resp.json() == [] + + async def test_delete_missing_returns_404(self, client): + resp = await client.delete("/api/segments/nonexistent") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Provider cache sync +# --------------------------------------------------------------------------- + + +class TestProviderCacheSync: + """Verify that REST operations propagate into the provider's in-memory cache.""" + + async def test_create_flag_populates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_create_sync") + await client.post("/api/flags", json=_bool_flag_payload()) + assert "my_flag" in engine._flag_provider._flags + + async def test_delete_flag_removes_from_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_del_sync") + await client.post("/api/flags", json=_bool_flag_payload()) + await client.delete("/api/flags/my_flag") + assert "my_flag" not in engine._flag_provider._flags + + async def test_create_segment_populates_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_seg_sync") + await client.post("/api/segments", json=_segment_payload()) + assert "beta" in engine._flag_provider._segments + + async def test_delete_segment_removes_from_provider_cache(self, client, engine): + engine.use_openfeature(domain="test_seg_del_sync") + await client.post("/api/segments", json=_segment_payload()) + await client.delete("/api/segments/beta") + assert "beta" not in engine._flag_provider._segments + + +# --------------------------------------------------------------------------- +# Evaluate with segment targeting +# --------------------------------------------------------------------------- + + +class TestEvaluateWithSegment: + async def test_segment_rule_resolves_correctly(self, client, engine): + # Create a segment + await client.post( + "/api/segments", + json={ + "key": "pro_users", + "name": "Pro Users", + "rules": [ + {"clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}]} + ], + }, + ) + + # Create a flag that targets the segment + payload = _bool_flag_payload() + payload["fallthrough"] = "off" + payload["rules"] = [ + { + "clauses": [{"attribute": "", "operator": "in_segment", "values": ["pro_users"]}], + "variation": "on", + } + ] + await client.post("/api/flags", json=payload) + + resp = await client.post( + "/api/flags/my_flag/evaluate", + json={"context": {"key": "user_1", "attributes": {"plan": "pro"}}}, + ) + assert resp.status_code == 200 + assert resp.json()["value"] is True + + +# --------------------------------------------------------------------------- +# Flag API — PATCH (partial update / LaunchDarkly-style mutation) +# --------------------------------------------------------------------------- + + +class TestPatchFlag: + async def test_patch_name(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"name": "Renamed Flag"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "Renamed Flag" + # Other fields untouched + assert resp.json()["type"] == "boolean" + assert resp.json()["key"] == "my_flag" + + async def test_patch_persists(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + await client.patch("/api/flags/my_flag", json={"name": "Persisted"}) + flag = await engine.get_flag("my_flag") + assert flag.name == "Persisted" + + async def test_patch_description(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"description": "hello"}) + assert resp.status_code == 200 + assert resp.json()["description"] == "hello" + + async def test_patch_off_variation(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"off_variation": "on"}) + assert resp.status_code == 200 + assert resp.json()["off_variation"] == "on" + + async def test_patch_fallthrough(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"fallthrough": "on"}) + assert resp.status_code == 200 + assert resp.json()["fallthrough"] == "on" + + async def test_patch_ignores_key(self, client): + """key must be immutable — PATCH body key field is silently ignored.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"key": "HACKED", "name": "ok"}) + assert resp.status_code == 200 + assert resp.json()["key"] == "my_flag" + + async def test_patch_ignores_type(self, client): + """type must be immutable — PATCH body type field is silently ignored.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", json={"type": "string", "name": "ok"}) + assert resp.status_code == 200 + assert resp.json()["type"] == "boolean" + + async def test_patch_missing_flag_returns_404(self, client): + resp = await client.patch("/api/flags/no_such_flag", json={"name": "x"}) + assert resp.status_code == 404 + + async def test_patch_invalid_body_returns_400(self, client): + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch("/api/flags/my_flag", content=b"not json") + assert resp.status_code == 400 + + async def test_patch_invalid_variation_name_returns_400(self, client): + """off_variation must reference an existing variation name.""" + await client.post("/api/flags", json=_bool_flag_payload()) + resp = await client.patch( + "/api/flags/my_flag", + json={"off_variation": "nonexistent_variation"}, + ) + assert resp.status_code == 400 + + async def test_patch_replaces_rules(self, client, engine): + await client.post("/api/flags", json=_bool_flag_payload()) + new_rules = [ + { + "clauses": [{"attribute": "plan", "operator": "is", "values": ["pro"]}], + "variation": "on", + } + ] + resp = await client.patch("/api/flags/my_flag", json={"rules": new_rules}) + assert resp.status_code == 200 + flag = await engine.get_flag("my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" diff --git a/tests/core/feature_flags/__init__.py b/tests/core/feature_flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/feature_flags/test_client.py b/tests/core/feature_flags/test_client.py new file mode 100644 index 0000000..6d50d7c --- /dev/null +++ b/tests/core/feature_flags/test_client.py @@ -0,0 +1,270 @@ +"""Tests for ShieldFeatureClient and engine.use_openfeature().""" + +from __future__ import annotations + +from shield.core.backends.memory import MemoryBackend +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.client import ShieldFeatureClient +from shield.core.feature_flags.models import ( + EvaluationContext, + FeatureFlag, + FlagType, + FlagVariation, +) +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key="feat", fallthrough_variation="on", enabled=True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="Feat", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=fallthrough_variation, + enabled=enabled, + ) + + +def _string_flag(key="color") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Color", + type=FlagType.STRING, + variations=[ + FlagVariation(name="blue", value="blue"), + FlagVariation(name="red", value="red"), + ], + off_variation="blue", + fallthrough="red", + ) + + +def _int_flag(key="limit") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Limit", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="low", value=10), + FlagVariation(name="high", value=100), + ], + off_variation="low", + fallthrough="high", + ) + + +def _float_flag(key="rate") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Rate", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="slow", value=0.1), + FlagVariation(name="fast", value=0.9), + ], + off_variation="slow", + fallthrough="fast", + ) + + +def _object_flag(key="cfg") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Config", + type=FlagType.JSON, + variations=[ + FlagVariation(name="default", value={"x": 1}), + FlagVariation(name="premium", value={"x": 10}), + ], + off_variation="default", + fallthrough="premium", + ) + + +class _FakeBackend: + def __init__(self, flags=()): + self._flags = list(flags) + + async def load_all_flags(self): + return self._flags + + async def load_all_segments(self): + return [] + + # Minimal stubs so ShieldEngine can use this as backend + async def startup(self): + pass + + async def shutdown(self): + pass + + async def subscribe_global_config(self): + raise NotImplementedError + + async def subscribe_route_state(self): + raise NotImplementedError + + async def subscribe_rate_limit_policies(self): + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# engine.use_openfeature() +# --------------------------------------------------------------------------- + + +class TestUseOpenFeature: + def test_returns_shield_feature_client(self): + engine = ShieldEngine() + client = engine.use_openfeature(domain="test_uof") + assert isinstance(client, ShieldFeatureClient) + + def test_flag_client_property_returns_client(self): + engine = ShieldEngine() + client = engine.use_openfeature(domain="test_prop") + assert engine.flag_client is client + + def test_flag_client_none_before_use_openfeature(self): + engine = ShieldEngine() + assert engine.flag_client is None + + def test_custom_provider_accepted(self): + engine = ShieldEngine() + custom = ShieldOpenFeatureProvider(MemoryBackend()) + client = engine.use_openfeature(provider=custom, domain="test_custom") + assert engine._flag_provider is custom + assert isinstance(client, ShieldFeatureClient) + + def test_default_provider_is_shield_provider(self): + engine = ShieldEngine() + engine.use_openfeature(domain="test_default_prov") + assert isinstance(engine._flag_provider, ShieldOpenFeatureProvider) + + async def test_start_initializes_provider(self): + initialized = [] + + class _TrackedProvider(ShieldOpenFeatureProvider): + def initialize(self, evaluation_context=None): + initialized.append(True) + + def shutdown(self): + pass + + engine = ShieldEngine() + engine.use_openfeature(provider=_TrackedProvider(MemoryBackend()), domain="test_start") + await engine.start() + assert initialized == [True] + await engine.stop() + + async def test_stop_shuts_down_provider(self): + shutdown = [] + + class _TrackedProvider(ShieldOpenFeatureProvider): + def initialize(self, evaluation_context=None): + pass + + def shutdown(self): + shutdown.append(True) + + engine = ShieldEngine() + engine.use_openfeature(provider=_TrackedProvider(MemoryBackend()), domain="test_stop") + await engine.start() + await engine.stop() + assert shutdown == [True] + + def test_use_openfeature_multiple_calls_replaces_provider(self): + engine = ShieldEngine() + engine.use_openfeature(domain="test_multi_1") + p1 = engine._flag_provider + engine.use_openfeature(domain="test_multi_2") + p2 = engine._flag_provider + # Both are valid providers; second call replaced the first. + assert p2 is not p1 + + +# --------------------------------------------------------------------------- +# ShieldFeatureClient — evaluation +# --------------------------------------------------------------------------- + + +class TestShieldFeatureClientEvaluation: + async def _make_client(self, flags, domain) -> ShieldFeatureClient: + """Wire up a provider with the given flags and return a client.""" + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=flags)) + await provider._load_all() + + import openfeature.api as of_api + + of_api.set_provider(provider, domain=domain) + return ShieldFeatureClient(domain=domain) + + async def test_get_boolean_value_true(self): + client = await self._make_client([_bool_flag(fallthrough_variation="on")], "cli_bool") + ctx = EvaluationContext(key="user_1") + result = await client.get_boolean_value("feat", False, ctx) + assert result is True + + async def test_get_boolean_value_missing_flag_returns_default(self): + client = await self._make_client([], "cli_bool_miss") + result = await client.get_boolean_value("missing", True) + assert result is True + + async def test_get_string_value(self): + client = await self._make_client([_string_flag()], "cli_str") + ctx = EvaluationContext(key="user_1") + result = await client.get_string_value("color", "default", ctx) + assert result == "red" + + async def test_get_string_value_missing_flag(self): + client = await self._make_client([], "cli_str_miss") + result = await client.get_string_value("missing", "fallback") + assert result == "fallback" + + async def test_get_integer_value(self): + client = await self._make_client([_int_flag()], "cli_int") + result = await client.get_integer_value("limit", 0) + assert result == 100 + + async def test_get_integer_value_missing(self): + client = await self._make_client([], "cli_int_miss") + result = await client.get_integer_value("limit", 99) + assert result == 99 + + async def test_get_float_value(self): + client = await self._make_client([_float_flag()], "cli_float") + result = await client.get_float_value("rate", 0.0) + assert abs(result - 0.9) < 1e-9 + + async def test_get_float_value_missing(self): + client = await self._make_client([], "cli_float_miss") + result = await client.get_float_value("rate", 1.5) + assert abs(result - 1.5) < 1e-9 + + async def test_get_object_value(self): + client = await self._make_client([_object_flag()], "cli_obj") + result = await client.get_object_value("cfg", {}) + assert result == {"x": 10} + + async def test_get_object_value_missing(self): + client = await self._make_client([], "cli_obj_miss") + result = await client.get_object_value("cfg", {"y": 2}) + assert result == {"y": 2} + + async def test_no_context_uses_anonymous(self): + client = await self._make_client([_bool_flag()], "cli_anon") + # Passing no context should not raise + result = await client.get_boolean_value("feat", False) + assert isinstance(result, bool) + + async def test_disabled_flag_returns_off_default(self): + client = await self._make_client([_bool_flag(enabled=False)], "cli_dis") + result = await client.get_boolean_value("feat", True) + assert result is False # off variation = False diff --git a/tests/core/feature_flags/test_evaluator.py b/tests/core/feature_flags/test_evaluator.py new file mode 100644 index 0000000..3ca0ac7 --- /dev/null +++ b/tests/core/feature_flags/test_evaluator.py @@ -0,0 +1,524 @@ +"""Tests for FlagEvaluator — pure unit tests, no I/O, no async. + +Every test constructs FeatureFlag / EvaluationContext objects directly +and calls FlagEvaluator.evaluate() synchronously. +""" + +from __future__ import annotations + +import pytest + +from shield.core.feature_flags.evaluator import FlagEvaluator +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + FlagType, + FlagVariation, + Operator, + Prerequisite, + RolloutVariation, + RuleClause, + Segment, + SegmentRule, + TargetingRule, +) + +# ── Fixtures and helpers ────────────────────────────────────────────────────── + + +def _flag( + key: str = "flag_a", + enabled: bool = True, + fallthrough: str | list = "off", + rules: list | None = None, + targets: dict | None = None, + prerequisites: list | None = None, + variations: list | None = None, +) -> FeatureFlag: + if variations is None: + variations = [ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ] + return FeatureFlag( + key=key, + name=key, + type=FlagType.BOOLEAN, + variations=variations, + off_variation="off", + fallthrough=fallthrough, + enabled=enabled, + rules=rules or [], + targets=targets or {}, + prerequisites=prerequisites or [], + ) + + +def _ctx(key: str = "user_1", **attrs: object) -> EvaluationContext: + return EvaluationContext(key=key, attributes=dict(attrs)) + + +def _rule(*clauses: RuleClause, variation: str = "on") -> TargetingRule: + return TargetingRule(clauses=list(clauses), variation=variation) + + +def _clause(attr: str, op: Operator, values: list) -> RuleClause: + return RuleClause(attribute=attr, operator=op, values=values) + + +@pytest.fixture +def evaluator() -> FlagEvaluator: + return FlagEvaluator(segments={}) + + +# ── Step 1: flag disabled ───────────────────────────────────────────────────── + + +class TestFlagDisabled: + def test_disabled_serves_off_variation(self, evaluator): + flag = _flag(enabled=False) + result = evaluator.evaluate(flag, _ctx(), {}) + assert result.value is False + assert result.variation == "off" + assert result.reason == EvaluationReason.OFF + + def test_disabled_ignores_rules(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(enabled=False, rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.reason == EvaluationReason.OFF + + def test_disabled_ignores_targets(self, evaluator): + flag = _flag(enabled=False, targets={"on": ["user_1"]}) + result = evaluator.evaluate(flag, _ctx("user_1"), {}) + assert result.reason == EvaluationReason.OFF + + +# ── Step 2: prerequisites ───────────────────────────────────────────────────── + + +class TestPrerequisites: + def test_prerequisite_met(self, evaluator): + auth_flag = _flag("auth_v2", fallthrough="on") + main_flag = _flag( + "checkout", + fallthrough="on", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + assert result.reason == EvaluationReason.FALLTHROUGH + assert result.value is True + + def test_prerequisite_not_met(self, evaluator): + auth_flag = _flag("auth_v2", fallthrough="off") + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + assert result.prerequisite_key == "auth_v2" + assert result.value is False + + def test_missing_prerequisite_flag(self, evaluator): + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="missing_flag", variation="on")], + ) + result = evaluator.evaluate(main_flag, _ctx(), {"checkout": main_flag}) + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + assert result.prerequisite_key == "missing_flag" + + def test_disabled_prerequisite_fails(self, evaluator): + auth_flag = _flag("auth_v2", enabled=False, fallthrough="on") + main_flag = _flag( + "checkout", + prerequisites=[Prerequisite(flag_key="auth_v2", variation="on")], + ) + all_flags = {"auth_v2": auth_flag, "checkout": main_flag} + result = evaluator.evaluate(main_flag, _ctx(), all_flags) + # auth_v2 is disabled → serves off_variation "off", not "on" → prereq fails + assert result.reason == EvaluationReason.PREREQUISITE_FAIL + + def test_depth_limit_protection(self, evaluator): + # Simulate deep recursion by calling with _depth at limit + flag = _flag( + "deep", + prerequisites=[Prerequisite(flag_key="other", variation="on")], + ) + result = evaluator.evaluate(flag, _ctx(), {flag.key: flag}, _depth=11) + assert result.reason == EvaluationReason.ERROR + + +# ── Step 3: individual targets ──────────────────────────────────────────────── + + +class TestIndividualTargets: + def test_targeted_context_served_correct_variation(self, evaluator): + flag = _flag(targets={"on": ["user_1", "user_2"], "off": ["user_99"]}) + result = evaluator.evaluate(flag, _ctx("user_1"), {}) + assert result.reason == EvaluationReason.TARGET_MATCH + assert result.variation == "on" + assert result.value is True + + def test_non_targeted_context_falls_through(self, evaluator): + flag = _flag(targets={"on": ["user_1"]}) + result = evaluator.evaluate(flag, _ctx("user_999"), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_targets_take_priority_over_rules(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"]), variation="off") + flag = _flag(targets={"on": ["user_1"]}, rules=[rule]) + result = evaluator.evaluate(flag, _ctx("user_1", role="admin"), {}) + # Individual target wins over matching rule + assert result.reason == EvaluationReason.TARGET_MATCH + assert result.variation == "on" + + +# ── Step 4: targeting rules ─────────────────────────────────────────────────── + + +class TestTargetingRules: + def test_single_clause_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.reason == EvaluationReason.RULE_MATCH + assert result.rule_id == rule.id + assert result.value is True + + def test_single_clause_no_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(role="user"), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_multiple_clauses_all_must_match(self, evaluator): + rule = _rule( + _clause("role", Operator.IS, ["admin"]), + _clause("plan", Operator.IS, ["pro"]), + ) + flag = _flag(rules=[rule]) + # Both match + r = evaluator.evaluate(flag, _ctx(role="admin", plan="pro"), {}) + assert r.reason == EvaluationReason.RULE_MATCH + # Only one matches + r = evaluator.evaluate(flag, _ctx(role="admin", plan="free"), {}) + assert r.reason == EvaluationReason.FALLTHROUGH + + def test_first_rule_wins(self, evaluator): + rule1 = TargetingRule( + id="rule1", + clauses=[_clause("role", Operator.IS, ["admin"])], + variation="on", + ) + rule2 = TargetingRule( + id="rule2", + clauses=[_clause("role", Operator.IS, ["admin"])], + variation="off", + ) + flag = _flag(rules=[rule1, rule2]) + result = evaluator.evaluate(flag, _ctx(role="admin"), {}) + assert result.rule_id == "rule1" + assert result.variation == "on" + + def test_missing_attribute_no_match(self, evaluator): + rule = _rule(_clause("role", Operator.IS, ["admin"])) + flag = _flag(rules=[rule]) + result = evaluator.evaluate(flag, _ctx(), {}) # no role attr + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_rule_with_rollout(self, evaluator): + # Force a specific bucket by using a known key + rollout_rule = TargetingRule( + clauses=[_clause("plan", Operator.IS, ["pro"])], + rollout=[ + RolloutVariation(variation="on", weight=100_000), + RolloutVariation(variation="off", weight=0), + ], + ) + flag = _flag(rules=[rollout_rule]) + result = evaluator.evaluate(flag, _ctx(plan="pro"), {}) + assert result.reason == EvaluationReason.RULE_MATCH + assert result.variation == "on" + + +# ── Step 5: fallthrough ─────────────────────────────────────────────────────── + + +class TestFallthrough: + def test_fixed_variation_fallthrough(self, evaluator): + flag = _flag(fallthrough="off") + result = evaluator.evaluate(flag, _ctx(), {}) + assert result.reason == EvaluationReason.FALLTHROUGH + assert result.variation == "off" + assert result.value is False + + def test_rollout_fallthrough_deterministic(self, evaluator): + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=100_000), + ] + ) + # 100% → always "on" + for i in range(10): + result = evaluator.evaluate(flag, _ctx(f"user_{i}"), {}) + assert result.variation == "on" + + def test_rollout_fallthrough_stable(self, evaluator): + """Same context always gets the same bucket.""" + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + ) + ctx = _ctx("stable_user") + first = evaluator.evaluate(flag, ctx, {}).variation + for _ in range(5): + assert evaluator.evaluate(flag, ctx, {}).variation == first + + +# ── Operator tests ──────────────────────────────────────────────────────────── + + +class TestOperators: + """One test per operator group.""" + + def _eval(self, evaluator, op, actual, values, negate=False): + clause = RuleClause(attribute="x", operator=op, values=values, negate=negate) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="u", attributes={"x": actual} if actual is not None else {}) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + # ── Equality + def test_is_match(self, evaluator): + assert self._eval(evaluator, Operator.IS, "admin", ["admin"]) + + def test_is_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IS, "user", ["admin"]) + + def test_is_not_match(self, evaluator): + assert self._eval(evaluator, Operator.IS_NOT, "user", ["admin"]) + + def test_is_not_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IS_NOT, "admin", ["admin"]) + + # ── String + def test_contains(self, evaluator): + assert self._eval(evaluator, Operator.CONTAINS, "hello world", ["world"]) + + def test_not_contains(self, evaluator): + assert self._eval(evaluator, Operator.NOT_CONTAINS, "hello", ["world"]) + + def test_starts_with(self, evaluator): + assert self._eval(evaluator, Operator.STARTS_WITH, "prefix_key", ["prefix"]) + + def test_ends_with(self, evaluator): + assert self._eval(evaluator, Operator.ENDS_WITH, "key_suffix", ["suffix"]) + + def test_matches_regex(self, evaluator): + assert self._eval(evaluator, Operator.MATCHES, "user@example.com", [r"@\w+\.com"]) + + def test_not_matches_regex(self, evaluator): + assert self._eval(evaluator, Operator.NOT_MATCHES, "foobar", [r"@\w+\.com"]) + + def test_invalid_regex_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.MATCHES, "test", ["[invalid"]) + + # ── Numeric + def test_gt(self, evaluator): + assert self._eval(evaluator, Operator.GT, 10, [5]) + + def test_gt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.GT, 3, [5]) + + def test_gte(self, evaluator): + assert self._eval(evaluator, Operator.GTE, 5, [5]) + + def test_lt(self, evaluator): + assert self._eval(evaluator, Operator.LT, 3, [5]) + + def test_lte(self, evaluator): + assert self._eval(evaluator, Operator.LTE, 5, [5]) + + def test_numeric_non_numeric_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.GT, "abc", [5]) + + # ── Date (lexicographic) + def test_before(self, evaluator): + assert self._eval(evaluator, Operator.BEFORE, "2025-01-01", ["2026-01-01"]) + + def test_after(self, evaluator): + assert self._eval(evaluator, Operator.AFTER, "2026-01-01", ["2025-01-01"]) + + # ── Collection + def test_in(self, evaluator): + assert self._eval(evaluator, Operator.IN, "admin", ["admin", "moderator"]) + + def test_in_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.IN, "user", ["admin"]) + + def test_not_in(self, evaluator): + assert self._eval(evaluator, Operator.NOT_IN, "user", ["admin"]) + + # ── Negate + def test_negate_reverses_result(self, evaluator): + assert self._eval(evaluator, Operator.IS, "admin", ["admin"], negate=False) + assert not self._eval(evaluator, Operator.IS, "admin", ["admin"], negate=True) + + # ── Multiple values (OR) + def test_multiple_values_any_match(self, evaluator): + assert self._eval(evaluator, Operator.IS, "moderator", ["admin", "moderator", "staff"]) + + # ── Missing attribute + def test_missing_attribute_is_not(self, evaluator): + # IS_NOT with None still works (None != "admin" is True) + assert self._eval(evaluator, Operator.IS_NOT, None, ["admin"]) + + def test_missing_attribute_in_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.IN, None, ["admin"]) + + +# ── Segment operator tests ──────────────────────────────────────────────────── + + +class TestSegmentOperator: + def _make_evaluator(self, **segments: Segment) -> FlagEvaluator: + return FlagEvaluator(segments=segments) + + def _eval_segment(self, evaluator, context_key, segment_key, negate=False): + op = Operator.NOT_IN_SEGMENT if negate else Operator.IN_SEGMENT + clause = RuleClause(attribute="key", operator=op, values=[segment_key]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key=context_key, attributes={"plan": "pro"}) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + def test_in_segment_via_included_list(self): + seg = Segment(key="beta", name="Beta", included=["user_1"]) + ev = self._make_evaluator(beta=seg) + assert self._eval_segment(ev, "user_1", "beta") + + def test_not_in_segment_excluded(self): + seg = Segment(key="beta", name="Beta", included=["user_1"], excluded=["user_1"]) + ev = self._make_evaluator(beta=seg) + # excluded overrides included + assert not self._eval_segment(ev, "user_1", "beta") + + def test_in_segment_via_rule(self): + seg_rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro", rules=[seg_rule]) + ev = self._make_evaluator(pro_users=seg) + assert self._eval_segment(ev, "any_user", "pro_users") + + def test_not_in_segment_via_rule_no_match(self): + seg_rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IS, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro", rules=[seg_rule]) + ev = self._make_evaluator(pro_users=seg) + # Context has plan=free, not pro → not in segment + clause = RuleClause(attribute="key", operator=Operator.IN_SEGMENT, values=["pro_users"]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="user_free", attributes={"plan": "free"}) + result = ev.evaluate(flag, ctx, {}) + assert result.reason == EvaluationReason.FALLTHROUGH + + def test_missing_segment_logs_and_returns_false(self, caplog): + ev = FlagEvaluator(segments={}) + assert not self._eval_segment(ev, "user_1", "nonexistent_segment") + + def test_not_in_segment_operator(self): + seg = Segment(key="blocked", name="Blocked", included=["bad_user"]) + ev = self._make_evaluator(blocked=seg) + # good_user is NOT in blocked segment → NOT_IN_SEGMENT matches + assert self._eval_segment(ev, "good_user", "blocked", negate=True) + # bad_user IS in blocked segment → NOT_IN_SEGMENT does not match + assert not self._eval_segment(ev, "bad_user", "blocked", negate=True) + + +# ── Semver operator tests ───────────────────────────────────────────────────── + + +class TestSemverOperators: + def _eval(self, evaluator, op, actual, threshold): + clause = RuleClause(attribute="app_version", operator=op, values=[threshold]) + rule = TargetingRule(clauses=[clause], variation="on") + flag = _flag(rules=[rule]) + ctx = EvaluationContext(key="u", app_version=actual) + result = evaluator.evaluate(flag, ctx, {}) + return result.reason == EvaluationReason.RULE_MATCH + + def test_semver_eq(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_EQ, "2.3.1", "2.3.1") + + def test_semver_eq_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_EQ, "2.3.0", "2.3.1") + + def test_semver_lt(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_LT, "2.3.0", "2.3.1") + + def test_semver_lt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_LT, "2.3.1", "2.3.0") + + def test_semver_gt(self, evaluator): + assert self._eval(evaluator, Operator.SEMVER_GT, "3.0.0", "2.9.9") + + def test_semver_gt_no_match(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_GT, "2.0.0", "2.9.9") + + def test_semver_invalid_returns_false(self, evaluator): + assert not self._eval(evaluator, Operator.SEMVER_GT, "not-a-version", "1.0.0") + + +# ── Rollout bucket stability ────────────────────────────────────────────────── + + +class TestRolloutBucketStability: + def test_bucket_is_deterministic(self): + ev = FlagEvaluator(segments={}) + flag = _flag( + fallthrough=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + ) + ctx = _ctx("fixed_key") + results = [ev.evaluate(flag, ctx, {}).variation for _ in range(20)] + assert len(set(results)) == 1 # always the same + + def test_different_flag_keys_different_buckets(self): + """Different flag keys produce different buckets for the same context.""" + ev = FlagEvaluator(segments={}) + ctx = _ctx("user_1") + rollout = [ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ] + flag_a = _flag("flag_a", fallthrough=rollout) + flag_b = _flag("flag_b", fallthrough=rollout) + results = { + ev.evaluate(flag_a, ctx, {}).variation, + ev.evaluate(flag_b, ctx, {}).variation, + } + # Not guaranteed to differ, but flag keys do affect the bucket + # so we just verify both evaluate without error + assert all(r in ("on", "off") for r in results) + + def test_weights_sum_100k_covers_all(self): + """100% weight on one variation → all contexts get it.""" + ev = FlagEvaluator(segments={}) + flag = _flag(fallthrough=[RolloutVariation(variation="on", weight=100_000)]) + for i in range(50): + r = ev.evaluate(flag, _ctx(f"user_{i}"), {}) + assert r.variation == "on" diff --git a/tests/core/feature_flags/test_models.py b/tests/core/feature_flags/test_models.py new file mode 100644 index 0000000..b075e6f --- /dev/null +++ b/tests/core/feature_flags/test_models.py @@ -0,0 +1,424 @@ +"""Tests for shield.core.feature_flags.models. + +All tests are sync and pure — no I/O, no async, no openfeature dependency. +Models are importable without the [flags] extra. +""" + +from __future__ import annotations + +from datetime import UTC + +import pytest +from pydantic import ValidationError + +from shield.core.feature_flags.models import ( + EvaluationContext, + EvaluationReason, + FeatureFlag, + FlagStatus, + FlagType, + FlagVariation, + Operator, + Prerequisite, + ResolutionDetails, + RolloutVariation, + RuleClause, + ScheduledChange, + ScheduledChangeAction, + Segment, + SegmentRule, + TargetingRule, +) + +# ── FlagType ───────────────────────────────────────────────────────────────── + + +class TestFlagType: + def test_all_values(self): + assert FlagType.BOOLEAN == "boolean" + assert FlagType.STRING == "string" + assert FlagType.INTEGER == "integer" + assert FlagType.FLOAT == "float" + assert FlagType.JSON == "json" + + +# ── FlagVariation ───────────────────────────────────────────────────────────── + + +class TestFlagVariation: + def test_boolean_variation(self): + v = FlagVariation(name="on", value=True) + assert v.name == "on" + assert v.value is True + assert v.description == "" + + def test_string_variation(self): + v = FlagVariation(name="blue", value="blue", description="Blue variant") + assert v.value == "blue" + assert v.description == "Blue variant" + + def test_json_variation(self): + v = FlagVariation(name="config", value={"limit": 100, "burst": 20}) + assert v.value == {"limit": 100, "burst": 20} + + def test_list_variation(self): + v = FlagVariation(name="tags", value=["a", "b"]) + assert v.value == ["a", "b"] + + +# ── RolloutVariation ────────────────────────────────────────────────────────── + + +class TestRolloutVariation: + def test_valid(self): + rv = RolloutVariation(variation="on", weight=25_000) + assert rv.variation == "on" + assert rv.weight == 25_000 + + def test_weight_zero(self): + rv = RolloutVariation(variation="off", weight=0) + assert rv.weight == 0 + + def test_weight_max(self): + rv = RolloutVariation(variation="on", weight=100_000) + assert rv.weight == 100_000 + + def test_weight_over_max_rejected(self): + with pytest.raises(ValidationError): + RolloutVariation(variation="on", weight=100_001) + + def test_weight_negative_rejected(self): + with pytest.raises(ValidationError): + RolloutVariation(variation="on", weight=-1) + + +# ── Operator ────────────────────────────────────────────────────────────────── + + +class TestOperator: + def test_all_operators_present(self): + expected = { + "is", + "is_not", + "contains", + "not_contains", + "starts_with", + "ends_with", + "matches", + "not_matches", + "gt", + "gte", + "lt", + "lte", + "before", + "after", + "in", + "not_in", + "in_segment", + "not_in_segment", + "semver_eq", + "semver_lt", + "semver_gt", + } + actual = {op.value for op in Operator} + assert actual == expected + + +# ── RuleClause ──────────────────────────────────────────────────────────────── + + +class TestRuleClause: + def test_basic(self): + clause = RuleClause(attribute="role", operator=Operator.IS, values=["admin"]) + assert clause.attribute == "role" + assert clause.operator == Operator.IS + assert clause.values == ["admin"] + assert clause.negate is False + + def test_negated(self): + clause = RuleClause(attribute="plan", operator=Operator.IN, values=["free"], negate=True) + assert clause.negate is True + + def test_multiple_values(self): + clause = RuleClause( + attribute="role", operator=Operator.IN, values=["admin", "moderator", "staff"] + ) + assert len(clause.values) == 3 + + +# ── TargetingRule ───────────────────────────────────────────────────────────── + + +class TestTargetingRule: + def test_auto_id(self): + rule = TargetingRule() + assert len(rule.id) == 36 # UUID4 format + + def test_with_fixed_variation(self): + rule = TargetingRule( + clauses=[RuleClause(attribute="role", operator=Operator.IS, values=["admin"])], + variation="on", + ) + assert rule.variation == "on" + assert rule.rollout is None + + def test_with_rollout(self): + rule = TargetingRule( + clauses=[], + rollout=[ + RolloutVariation(variation="on", weight=50_000), + RolloutVariation(variation="off", weight=50_000), + ], + ) + assert rule.variation is None + assert len(rule.rollout) == 2 + + def test_custom_id(self): + rule = TargetingRule(id="my-rule-id") + assert rule.id == "my-rule-id" + + +# ── Prerequisite ────────────────────────────────────────────────────────────── + + +class TestPrerequisite: + def test_basic(self): + prereq = Prerequisite(flag_key="auth_v2", variation="on") + assert prereq.flag_key == "auth_v2" + assert prereq.variation == "on" + + +# ── Segment ─────────────────────────────────────────────────────────────────── + + +class TestSegment: + def test_minimal(self): + seg = Segment(key="beta", name="Beta Users") + assert seg.key == "beta" + assert seg.included == [] + assert seg.excluded == [] + assert seg.rules == [] + + def test_with_members(self): + seg = Segment( + key="beta", + name="Beta", + included=["user_1", "user_2"], + excluded=["user_99"], + ) + assert "user_1" in seg.included + assert "user_99" in seg.excluded + + def test_with_rules(self): + rule = SegmentRule( + clauses=[RuleClause(attribute="plan", operator=Operator.IN, values=["pro"])] + ) + seg = Segment(key="pro_users", name="Pro Users", rules=[rule]) + assert len(seg.rules) == 1 + + +# ── ScheduledChange ─────────────────────────────────────────────────────────── + + +class TestScheduledChange: + def test_auto_id(self): + from datetime import datetime + + sc = ScheduledChange( + execute_at=datetime(2026, 4, 1, 9, 0, tzinfo=UTC), + action=ScheduledChangeAction.ENABLE, + ) + assert len(sc.id) == 36 + assert sc.action == ScheduledChangeAction.ENABLE + assert sc.created_by == "system" + + def test_all_actions(self): + assert ScheduledChangeAction.ENABLE == "enable" + assert ScheduledChangeAction.DISABLE == "disable" + assert ScheduledChangeAction.UPDATE_ROLLOUT == "update_rollout" + assert ScheduledChangeAction.ADD_RULE == "add_rule" + assert ScheduledChangeAction.DELETE_RULE == "delete_rule" + + +# ── FeatureFlag ─────────────────────────────────────────────────────────────── + + +def _make_boolean_flag(key: str = "my_flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + enabled=enabled, + ) + + +class TestFeatureFlag: + def test_minimal_boolean_flag(self): + flag = _make_boolean_flag() + assert flag.key == "my_flag" + assert flag.type == FlagType.BOOLEAN + assert flag.enabled is True + assert flag.status == FlagStatus.ACTIVE + assert flag.temporary is True + + def test_get_variation_value_found(self): + flag = _make_boolean_flag() + assert flag.get_variation_value("on") is True + assert flag.get_variation_value("off") is False + + def test_get_variation_value_missing(self): + flag = _make_boolean_flag() + assert flag.get_variation_value("nonexistent") is None + + def test_variation_names(self): + flag = _make_boolean_flag() + assert flag.variation_names() == ["on", "off"] + + def test_with_rollout_fallthrough(self): + flag = FeatureFlag( + key="rollout_flag", + name="Rollout", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough=[ + RolloutVariation(variation="on", weight=25_000), + RolloutVariation(variation="off", weight=75_000), + ], + ) + assert isinstance(flag.fallthrough, list) + assert len(flag.fallthrough) == 2 + + def test_with_prerequisites(self): + flag = _make_boolean_flag() + flag.prerequisites = [Prerequisite(flag_key="auth_v2", variation="on")] + assert len(flag.prerequisites) == 1 + + def test_with_targets(self): + flag = _make_boolean_flag() + flag.targets = {"on": ["user_1", "user_2"]} + assert "user_1" in flag.targets["on"] + + def test_disabled_flag(self): + flag = _make_boolean_flag(enabled=False) + assert flag.enabled is False + + +# ── EvaluationContext ───────────────────────────────────────────────────────── + + +class TestEvaluationContext: + def test_minimal(self): + ctx = EvaluationContext(key="user_123") + assert ctx.key == "user_123" + assert ctx.kind == "user" + assert ctx.attributes == {} + + def test_all_named_fields(self): + ctx = EvaluationContext( + key="user_1", + kind="user", + email="user@example.com", + ip="1.2.3.4", + country="US", + app_version="2.3.1", + ) + assert ctx.email == "user@example.com" + assert ctx.ip == "1.2.3.4" + assert ctx.country == "US" + assert ctx.app_version == "2.3.1" + + def test_all_attributes_merges_fields(self): + ctx = EvaluationContext( + key="user_1", + kind="user", + email="a@b.com", + country="UK", + attributes={"plan": "pro", "role": "admin"}, + ) + attrs = ctx.all_attributes() + assert attrs["key"] == "user_1" + assert attrs["kind"] == "user" + assert attrs["email"] == "a@b.com" + assert attrs["country"] == "UK" + assert attrs["plan"] == "pro" + assert attrs["role"] == "admin" + + def test_attributes_override_named_fields(self): + """attributes dict wins over named fields when keys collide.""" + ctx = EvaluationContext( + key="user_1", + country="US", + attributes={"country": "UK"}, # should win + ) + attrs = ctx.all_attributes() + assert attrs["country"] == "UK" + + def test_none_named_fields_excluded(self): + ctx = EvaluationContext(key="user_1") + attrs = ctx.all_attributes() + assert "email" not in attrs + assert "ip" not in attrs + assert "country" not in attrs + assert "app_version" not in attrs + + def test_custom_kind(self): + ctx = EvaluationContext(key="org_42", kind="organization") + assert ctx.kind == "organization" + assert ctx.all_attributes()["kind"] == "organization" + + +# ── ResolutionDetails ───────────────────────────────────────────────────────── + + +class TestResolutionDetails: + def test_rule_match(self): + r = ResolutionDetails( + value=True, + variation="on", + reason=EvaluationReason.RULE_MATCH, + rule_id="rule_abc", + ) + assert r.value is True + assert r.variation == "on" + assert r.reason == EvaluationReason.RULE_MATCH + assert r.rule_id == "rule_abc" + assert r.prerequisite_key is None + + def test_prerequisite_fail(self): + r = ResolutionDetails( + value=False, + variation="off", + reason=EvaluationReason.PREREQUISITE_FAIL, + prerequisite_key="auth_v2", + ) + assert r.prerequisite_key == "auth_v2" + + def test_error(self): + r = ResolutionDetails( + value=False, + reason=EvaluationReason.ERROR, + error_message="Provider timeout", + ) + assert r.error_message == "Provider timeout" + assert r.variation is None + + def test_all_reasons_valid(self): + reasons = list(EvaluationReason) + assert len(reasons) == 7 + assert EvaluationReason.OFF in reasons + assert EvaluationReason.FALLTHROUGH in reasons + assert EvaluationReason.TARGET_MATCH in reasons + assert EvaluationReason.RULE_MATCH in reasons + assert EvaluationReason.PREREQUISITE_FAIL in reasons + assert EvaluationReason.ERROR in reasons + assert EvaluationReason.DEFAULT in reasons diff --git a/tests/core/feature_flags/test_provider.py b/tests/core/feature_flags/test_provider.py new file mode 100644 index 0000000..98d75f6 --- /dev/null +++ b/tests/core/feature_flags/test_provider.py @@ -0,0 +1,529 @@ +"""Tests for shield.core.feature_flags.provider — ShieldOpenFeatureProvider.""" + +from __future__ import annotations + +from openfeature.flag_evaluation import Reason + +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RuleClause, + Segment, + SegmentRule, + TargetingRule, +) +from shield.core.feature_flags.provider import ShieldOpenFeatureProvider + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +class _FakeBackend: + """Minimal in-memory backend stub — no real I/O.""" + + def __init__(self, flags=(), segments=()): + self._flags = list(flags) + self._segments = list(segments) + + async def load_all_flags(self): + return self._flags + + async def load_all_segments(self): + return self._segments + + +class _NoFlagBackend: + """Backend that doesn't implement flag storage (pre-Phase 3).""" + + async def load_all_flags(self): + raise AttributeError("not supported") + + async def load_all_segments(self): + raise AttributeError("not supported") + + +def _bool_flag(key="my_flag", enabled=True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + enabled=enabled, + ) + + +def _string_flag(key="color_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Color Flag", + type=FlagType.STRING, + variations=[ + FlagVariation(name="blue", value="blue"), + FlagVariation(name="red", value="red"), + ], + off_variation="blue", + fallthrough="red", + ) + + +def _int_flag(key="limit_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Limit Flag", + type=FlagType.INTEGER, + variations=[ + FlagVariation(name="low", value=10), + FlagVariation(name="high", value=100), + ], + off_variation="low", + fallthrough="high", + ) + + +def _float_flag(key="rate_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Rate Flag", + type=FlagType.FLOAT, + variations=[ + FlagVariation(name="low", value=0.1), + FlagVariation(name="high", value=0.9), + ], + off_variation="low", + fallthrough="high", + ) + + +def _object_flag(key="config_flag") -> FeatureFlag: + return FeatureFlag( + key=key, + name="Config Flag", + type=FlagType.JSON, + variations=[ + FlagVariation(name="default", value={"limit": 10}), + FlagVariation(name="premium", value={"limit": 100}), + ], + off_variation="default", + fallthrough="premium", + ) + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +class TestProviderMetadata: + def test_name(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + assert provider.get_metadata().name == "shield" + + def test_hooks_empty(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + assert provider.get_provider_hooks() == [] + + +# --------------------------------------------------------------------------- +# initialize — loads flags and segments +# --------------------------------------------------------------------------- + + +class TestInitialize: + async def test_loads_flags_and_segments(self): + flag = _bool_flag() + seg = Segment(key="beta", name="Beta") + backend = _FakeBackend(flags=[flag], segments=[seg]) + provider = ShieldOpenFeatureProvider(backend) + + await provider._load_all() + + assert "my_flag" in provider._flags + assert "beta" in provider._segments + + async def test_graceful_on_missing_backend_support(self): + provider = ShieldOpenFeatureProvider(_NoFlagBackend()) + # Should not raise — operate with empty caches + await provider._load_all() + assert provider._flags == {} + assert provider._segments == {} + + def test_shutdown_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.shutdown() # must not raise + + +# --------------------------------------------------------------------------- +# resolve_boolean_details +# --------------------------------------------------------------------------- + + +class TestResolveBooleanDetails: + async def test_flag_not_found_returns_default(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_boolean_details("missing", True) + assert result.value is True + assert result.error_code == "FLAG_NOT_FOUND" + assert result.reason == Reason.DEFAULT + + async def test_disabled_flag_returns_off_variation(self): + flag = _bool_flag(enabled=False) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.value is False + assert result.reason == Reason.DISABLED + + async def test_enabled_flag_fallthrough(self): + flag = FeatureFlag( + key="feat", + name="Feat", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("feat", False) + assert result.value is True + assert result.reason == Reason.DEFAULT + + async def test_type_coercion_fallback_on_mismatch(self): + # String flag evaluated as boolean — should return default + flag = _string_flag(key="color") + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("color", True) + # "red" cannot be cast to bool cleanly (it IS truthy in Python), + # so it returns bool("red") == True — coercion succeeds here. + assert isinstance(result.value, bool) + + +# --------------------------------------------------------------------------- +# resolve_string_details +# --------------------------------------------------------------------------- + + +class TestResolveStringDetails: + async def test_string_fallthrough(self): + flag = _string_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_string_details("color_flag", "default") + assert result.value == "red" + + async def test_string_missing_returns_default(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_string_details("missing", "fallback") + assert result.value == "fallback" + assert result.error_code == "FLAG_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# resolve_integer_details +# --------------------------------------------------------------------------- + + +class TestResolveIntegerDetails: + async def test_integer_fallthrough(self): + flag = _int_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_integer_details("limit_flag", 0) + assert result.value == 100 + + async def test_integer_missing(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_integer_details("nope", 42) + assert result.value == 42 + + +# --------------------------------------------------------------------------- +# resolve_float_details +# --------------------------------------------------------------------------- + + +class TestResolveFloatDetails: + async def test_float_fallthrough(self): + flag = _float_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_float_details("rate_flag", 0.0) + assert abs(result.value - 0.9) < 1e-9 + + async def test_float_coercion_from_int(self): + flag = _int_flag(key="int_flag") + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_float_details("int_flag", 0.0) + assert result.value == float(100) + + +# --------------------------------------------------------------------------- +# resolve_object_details +# --------------------------------------------------------------------------- + + +class TestResolveObjectDetails: + async def test_object_fallthrough(self): + flag = _object_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_object_details("config_flag", {}) + assert result.value == {"limit": 100} + + async def test_object_missing(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + await provider._load_all() + + result = provider.resolve_object_details("nope", {"x": 1}) + assert result.value == {"x": 1} + + +# --------------------------------------------------------------------------- +# Targeting — individual targets +# --------------------------------------------------------------------------- + + +class TestTargeting: + async def test_individual_target_match(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="beta_flag", + name="Beta", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + targets={"on": ["user_1", "user_2"]}, + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1") + result = provider.resolve_boolean_details("beta_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + async def test_individual_target_miss(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="beta_flag", + name="Beta", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + targets={"on": ["user_1"]}, + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_99") + result = provider.resolve_boolean_details("beta_flag", False, ctx) + assert result.value is False # fallthrough + + +# --------------------------------------------------------------------------- +# Targeting rules +# --------------------------------------------------------------------------- + + +class TestTargetingRules: + async def test_rule_match_reason(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + flag = FeatureFlag( + key="admin_flag", + name="Admin Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + rules=[ + TargetingRule( + clauses=[ + RuleClause( + attribute="role", + operator=Operator.IS, + values=["admin"], + ) + ], + variation="on", + ) + ], + ) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1", attributes={"role": "admin"}) + result = provider.resolve_boolean_details("admin_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH + + +# --------------------------------------------------------------------------- +# flag_metadata +# --------------------------------------------------------------------------- + + +class TestFlagMetadata: + async def test_metadata_keys_present(self): + flag = _bool_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + # Metadata keys are only present when non-None; a FALLTHROUGH/DEFAULT + # evaluation produces no rule_id or prerequisite_key. + assert isinstance(result.flag_metadata, dict) + + +# --------------------------------------------------------------------------- +# Cache management (upsert / delete) +# --------------------------------------------------------------------------- + + +class TestCacheManagement: + def test_upsert_flag(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + flag = _bool_flag() + provider.upsert_flag(flag) + assert "my_flag" in provider._flags + + def test_delete_flag(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + flag = _bool_flag() + provider.upsert_flag(flag) + provider.delete_flag("my_flag") + assert "my_flag" not in provider._flags + + def test_delete_flag_missing_is_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.delete_flag("nonexistent") # must not raise + + def test_upsert_segment(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + seg = Segment(key="beta", name="Beta") + provider.upsert_segment(seg) + assert "beta" in provider._segments + + def test_delete_segment(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + seg = Segment(key="beta", name="Beta") + provider.upsert_segment(seg) + provider.delete_segment("beta") + assert "beta" not in provider._segments + + def test_delete_segment_missing_is_noop(self): + provider = ShieldOpenFeatureProvider(_FakeBackend()) + provider.delete_segment("nonexistent") # must not raise + + +# --------------------------------------------------------------------------- +# Reason mapping +# --------------------------------------------------------------------------- + + +class TestReasonMapping: + async def test_off_reason_maps_to_disabled(self): + flag = _bool_flag(enabled=False) + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.reason == Reason.DISABLED + + async def test_fallthrough_reason_maps_to_default(self): + flag = _bool_flag() + provider = ShieldOpenFeatureProvider(_FakeBackend(flags=[flag])) + await provider._load_all() + + result = provider.resolve_boolean_details("my_flag", True) + assert result.reason == Reason.DEFAULT + + async def test_segment_rule_maps_to_targeting_match(self): + from openfeature.evaluation_context import EvaluationContext as OFCtx + + seg = Segment( + key="pro_users", + name="Pro", + rules=[ + SegmentRule( + clauses=[ + RuleClause( + attribute="plan", + operator=Operator.IS, + values=["pro"], + ) + ] + ) + ], + ) + flag = FeatureFlag( + key="pro_flag", + name="Pro Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="off", + rules=[ + TargetingRule( + clauses=[ + RuleClause( + attribute="", + operator=Operator.IN_SEGMENT, + values=["pro_users"], + ) + ], + variation="on", + ) + ], + ) + backend = _FakeBackend(flags=[flag], segments=[seg]) + provider = ShieldOpenFeatureProvider(backend) + await provider._load_all() + + ctx = OFCtx(targeting_key="user_1", attributes={"plan": "pro"}) + result = provider.resolve_boolean_details("pro_flag", False, ctx) + assert result.value is True + assert result.reason == Reason.TARGETING_MATCH diff --git a/tests/core/feature_flags/test_scheduler.py b/tests/core/feature_flags/test_scheduler.py new file mode 100644 index 0000000..7811d73 --- /dev/null +++ b/tests/core/feature_flags/test_scheduler.py @@ -0,0 +1,348 @@ +"""Tests for FlagScheduler — scheduled flag change runner.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Operator, + RuleClause, + ScheduledChange, + ScheduledChangeAction, + TargetingRule, +) +from shield.core.feature_flags.scheduler import FlagScheduler + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key: str, enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key.title(), + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _change( + action: ScheduledChangeAction, + delta_seconds: float = 0.05, + payload: dict | None = None, +) -> ScheduledChange: + return ScheduledChange( + execute_at=datetime.now(UTC) + timedelta(seconds=delta_seconds), + action=action, + payload=payload or {}, + ) + + +def _past_change(action: ScheduledChangeAction, payload: dict | None = None) -> ScheduledChange: + return ScheduledChange( + execute_at=datetime.now(UTC) - timedelta(hours=2), + action=action, + payload=payload or {}, + ) + + +# --------------------------------------------------------------------------- +# Basic construction / lifecycle +# --------------------------------------------------------------------------- + + +class TestFlagSchedulerLifecycle: + async def test_start_with_no_flags(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.start() # should not raise + await scheduler.stop() + + async def test_stop_with_no_tasks(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.stop() # idempotent + + async def test_list_pending_empty(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + assert scheduler.list_pending() == [] + + async def test_list_pending_after_schedule(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("pending-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await scheduler.schedule("pending-flag", change) + pending = scheduler.list_pending() + assert len(pending) == 1 + assert pending[0]["flag_key"] == "pending-flag" + assert pending[0]["change_id"] == change.id + await scheduler.stop() + + async def test_cancel_removes_task(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("cancel-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await scheduler.schedule("cancel-flag", change) + assert len(scheduler.list_pending()) == 1 + await scheduler.cancel("cancel-flag", change.id) + assert scheduler.list_pending() == [] + + async def test_cancel_nonexistent_is_noop(self) -> None: + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.cancel("ghost-flag", "ghost-id") # should not raise + + async def test_cancel_all_for_flag(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("multi-change-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + c1 = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + c2 = _change(ScheduledChangeAction.ENABLE, delta_seconds=120) + await scheduler.schedule("multi-change-flag", c1) + await scheduler.schedule("multi-change-flag", c2) + assert len(scheduler.list_pending()) == 2 + await scheduler.cancel_all_for_flag("multi-change-flag") + assert scheduler.list_pending() == [] + + async def test_stop_cancels_all(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("stop-all-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + c1 = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + c2 = _change(ScheduledChangeAction.ENABLE, delta_seconds=120) + await scheduler.schedule("stop-all-flag", c1) + await scheduler.schedule("stop-all-flag", c2) + await scheduler.stop() + assert scheduler.list_pending() == [] + + +# --------------------------------------------------------------------------- +# Action execution — ENABLE +# --------------------------------------------------------------------------- + + +class TestScheduledEnable: + async def test_enable_action_fires(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("enable-me", enabled=False) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.05) + await scheduler.schedule("enable-me", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("enable-me") + assert updated.enabled is True + + async def test_enable_removes_change_from_flag(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rm-change-enable", enabled=False) + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.05) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + await scheduler.schedule("rm-change-enable", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("rm-change-enable") + assert all(c.id != change.id for c in updated.scheduled_changes) + + +# --------------------------------------------------------------------------- +# Action execution — DISABLE +# --------------------------------------------------------------------------- + + +class TestScheduledDisable: + async def test_disable_action_fires(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("disable-me", enabled=True) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=0.05) + await scheduler.schedule("disable-me", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("disable-me") + assert updated.enabled is False + + +# --------------------------------------------------------------------------- +# Action execution — UPDATE_ROLLOUT +# --------------------------------------------------------------------------- + + +class TestScheduledUpdateRollout: + async def test_update_rollout_changes_fallthrough(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rollout-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change( + ScheduledChangeAction.UPDATE_ROLLOUT, + delta_seconds=0.05, + payload={"variation": "off"}, + ) + await scheduler.schedule("rollout-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("rollout-flag") + assert updated.fallthrough == "off" + + async def test_update_rollout_missing_payload_does_not_crash(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("rollout-flag2") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + # payload is empty — should log warning, not crash + change = _change(ScheduledChangeAction.UPDATE_ROLLOUT, delta_seconds=0.05, payload={}) + await scheduler.schedule("rollout-flag2", change) + await asyncio.sleep(0.3) + # Flag should still exist unchanged + still_there = await engine.get_flag("rollout-flag2") + assert still_there is not None + + +# --------------------------------------------------------------------------- +# Action execution — ADD_RULE / DELETE_RULE +# --------------------------------------------------------------------------- + + +class TestScheduledRuleMutations: + async def test_add_rule_appends(self) -> None: + engine = ShieldEngine() + flag = _bool_flag("add-rule-flag") + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + rule_payload = { + "id": "r-new", + "clauses": [{"attribute": "email", "operator": "ends_with", "values": ["@acme.com"]}], + "variation": "on", + } + change = _change( + ScheduledChangeAction.ADD_RULE, + delta_seconds=0.05, + payload=rule_payload, + ) + await scheduler.schedule("add-rule-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("add-rule-flag") + assert any(r.id == "r-new" for r in updated.rules) + + async def test_delete_rule_removes(self) -> None: + engine = ShieldEngine() + rule = TargetingRule( + id="r-del", + clauses=[RuleClause(attribute="role", operator=Operator.IN, values=["admin"])], + variation="on", + ) + flag = _bool_flag("del-rule-flag") + flag = flag.model_copy(update={"rules": [rule]}) + await engine.save_flag(flag) + scheduler = FlagScheduler(engine) + change = _change( + ScheduledChangeAction.DELETE_RULE, + delta_seconds=0.05, + payload={"rule_id": "r-del"}, + ) + await scheduler.schedule("del-rule-flag", change) + await asyncio.sleep(0.3) + updated = await engine.get_flag("del-rule-flag") + assert all(r.id != "r-del" for r in updated.rules) + + +# --------------------------------------------------------------------------- +# Start — restart recovery from backend +# --------------------------------------------------------------------------- + + +class TestSchedulerStartRecovery: + async def test_start_schedules_future_changes(self) -> None: + engine = ShieldEngine() + change = _change(ScheduledChangeAction.ENABLE, delta_seconds=0.1) + flag = _bool_flag("recovery-flag", enabled=False) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + + scheduler = FlagScheduler(engine) + await scheduler.start() + assert len(scheduler.list_pending()) == 1 + await asyncio.sleep(0.4) + updated = await engine.get_flag("recovery-flag") + assert updated.enabled is True + await scheduler.stop() + + async def test_start_skips_past_changes(self) -> None: + engine = ShieldEngine() + change = _past_change(ScheduledChangeAction.ENABLE) + flag = _bool_flag("past-change-flag", enabled=False) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + + scheduler = FlagScheduler(engine) + await scheduler.start() + # Past changes don't get a task + assert scheduler.list_pending() == [] + await scheduler.stop() + + async def test_start_ignores_missing_flag(self) -> None: + """start() should not crash if a flag disappears between load and task run.""" + engine = ShieldEngine() + scheduler = FlagScheduler(engine) + await scheduler.start() # no flags + await scheduler.stop() + + +# --------------------------------------------------------------------------- +# Engine integration — engine.start() wires FlagScheduler +# --------------------------------------------------------------------------- + + +class TestEngineIntegration: + async def test_engine_flag_scheduler_property(self) -> None: + engine = ShieldEngine() + assert engine.flag_scheduler is None + engine.use_openfeature() + assert engine.flag_scheduler is not None + + async def test_engine_start_starts_scheduler(self) -> None: + engine = ShieldEngine() + engine.use_openfeature() + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=0.1) + flag = _bool_flag("eng-sched-flag", enabled=True) + flag = flag.model_copy(update={"scheduled_changes": [change]}) + await engine.save_flag(flag) + await engine.start() + assert len(engine.flag_scheduler.list_pending()) == 1 + await asyncio.sleep(0.4) + updated = await engine.get_flag("eng-sched-flag") + assert updated.enabled is False + await engine.stop() + + async def test_engine_stop_stops_scheduler(self) -> None: + engine = ShieldEngine() + engine.use_openfeature() + await engine.start() + # Add a long-running task + flag = _bool_flag("stop-eng-flag") + await engine.save_flag(flag) + change = _change(ScheduledChangeAction.DISABLE, delta_seconds=60) + await engine.flag_scheduler.schedule("stop-eng-flag", change) + assert len(engine.flag_scheduler.list_pending()) == 1 + await engine.stop() + assert engine.flag_scheduler.list_pending() == [] diff --git a/tests/core/feature_flags/test_sync_client.py b/tests/core/feature_flags/test_sync_client.py new file mode 100644 index 0000000..4536f79 --- /dev/null +++ b/tests/core/feature_flags/test_sync_client.py @@ -0,0 +1,222 @@ +"""Tests for _SyncShieldFeatureClient and engine.sync.flag_client. + +Verifies that all five evaluation methods work correctly from a +synchronous context (the way FastAPI runs ``def`` route handlers). +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("openfeature", reason="api-shield[flags] not installed") + +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.client import ShieldFeatureClient, _SyncShieldFeatureClient +from shield.core.feature_flags.models import FeatureFlag, FlagType, FlagVariation + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_engine() -> ShieldEngine: + engine = ShieldEngine() + engine.use_openfeature() + return engine + + +def _make_flag( + key: str, ftype: FlagType, variations: list[FlagVariation], fallthrough: str +) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key, + type=ftype, + enabled=True, + variations=variations, + off_variation=variations[-1].name, + fallthrough=fallthrough, + ) + + +# --------------------------------------------------------------------------- +# _SyncShieldFeatureClient — unit +# --------------------------------------------------------------------------- + + +class TestSyncShieldFeatureClient: + def test_is_returned_by_flag_client_sync_property(self) -> None: + engine = _make_engine() + fc: ShieldFeatureClient = engine._flag_client + assert isinstance(fc.sync, _SyncShieldFeatureClient) + + def test_each_call_returns_fresh_instance(self) -> None: + engine = _make_engine() + fc: ShieldFeatureClient = engine._flag_client + # Two accesses to .sync return separate objects (not cached), + # but both wrap the same underlying OpenFeature client. + a = fc.sync + b = fc.sync + assert a._of_client is b._of_client + + def test_get_boolean_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value("unknown_flag", True) + assert result is True + + def test_get_string_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_string_value("unknown_flag", "fallback") + assert result == "fallback" + + def test_get_integer_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_integer_value("unknown_flag", 42) + assert result == 42 + + def test_get_float_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_float_value("unknown_flag", 3.14) + assert result == pytest.approx(3.14) + + def test_get_object_value_returns_default_for_unknown_flag(self) -> None: + engine = _make_engine() + default = {"k": "v"} + result = engine.sync.flag_client.get_object_value("unknown_flag", default) + assert result == default + + def test_ctx_dict_accepted(self) -> None: + """Passing a plain dict as ctx does not raise.""" + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value( + "unknown_flag", False, {"targeting_key": "user_123"} + ) + assert result is False + + def test_ctx_none_accepted(self) -> None: + engine = _make_engine() + result = engine.sync.flag_client.get_boolean_value("unknown_flag", True, None) + assert result is True + + +# --------------------------------------------------------------------------- +# engine.sync.flag_client integration +# --------------------------------------------------------------------------- + + +class TestEngineSyncFlagClient: + def test_returns_none_before_use_openfeature(self) -> None: + engine = ShieldEngine() + assert engine.sync.flag_client is None + + def test_returns_sync_client_after_use_openfeature(self) -> None: + engine = _make_engine() + fc = engine.sync.flag_client + assert fc is not None + assert isinstance(fc, _SyncShieldFeatureClient) + + def test_evaluates_registered_boolean_flag(self) -> None: + """A saved boolean flag returns its fallthrough value.""" + import asyncio + + engine = _make_engine() + + flag = _make_flag( + "beta_feature", + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_boolean_value("beta_feature", False) + assert result is True + + def test_evaluates_registered_string_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "theme", + FlagType.STRING, + [FlagVariation(name="dark", value="dark"), FlagVariation(name="light", value="light")], + "dark", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_string_value("theme", "light") + assert result == "dark" + + def test_evaluates_registered_integer_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "max_retries", + FlagType.INTEGER, + [FlagVariation(name="low", value=3), FlagVariation(name="high", value=10)], + "high", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_integer_value("max_retries", 1) + assert result == 10 + + def test_evaluates_registered_float_flag(self) -> None: + import asyncio + + engine = _make_engine() + flag = _make_flag( + "rate", + FlagType.FLOAT, + [FlagVariation(name="low", value=0.1), FlagVariation(name="high", value=0.9)], + "low", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_float_value("rate", 0.5) + assert result == pytest.approx(0.1) + + def test_disabled_flag_returns_default(self) -> None: + """A disabled flag always returns the default value.""" + import asyncio + + engine = _make_engine() + flag = FeatureFlag( + key="off_flag", + name="off_flag", + type=FlagType.BOOLEAN, + enabled=False, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + result = engine.sync.flag_client.get_boolean_value("off_flag", False) + # Disabled flag → OpenFeature returns the OFF variation or default + assert isinstance(result, bool) + + def test_sync_and_async_return_same_value(self) -> None: + """Sync and async evaluation of the same flag return identical results.""" + import asyncio + + engine = _make_engine() + flag = _make_flag( + "consistent", + FlagType.BOOLEAN, + [FlagVariation(name="on", value=True), FlagVariation(name="off", value=False)], + "on", + ) + asyncio.get_event_loop().run_until_complete(engine.save_flag(flag)) + + sync_result = engine.sync.flag_client.get_boolean_value("consistent", False) + + async def _async_eval() -> bool: + return await engine.flag_client.get_boolean_value("consistent", False) + + async_result = asyncio.get_event_loop().run_until_complete(_async_eval()) + assert sync_result == async_result diff --git a/tests/dashboard/test_flag_dashboard.py b/tests/dashboard/test_flag_dashboard.py new file mode 100644 index 0000000..df3e511 --- /dev/null +++ b/tests/dashboard/test_flag_dashboard.py @@ -0,0 +1,910 @@ +"""Tests for the flag + segment dashboard UI routes (Phase 5).""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def engine() -> ShieldEngine: + e = ShieldEngine() + await e.start() + yield e + await e.stop() + + +@pytest.fixture +def admin_app(engine: ShieldEngine) -> object: + """ShieldAdmin with flags enabled, no auth.""" + return ShieldAdmin(engine=engine, auth=None, enable_flags=True) + + +@pytest.fixture +async def client(admin_app: object) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin_app), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +@pytest.fixture +def admin_app_no_flags(engine: ShieldEngine) -> object: + """ShieldAdmin with flags disabled.""" + return ShieldAdmin(engine=engine, auth=None, enable_flags=False) + + +@pytest.fixture +async def client_no_flags(admin_app_no_flags: object) -> AsyncClient: + async with AsyncClient( + transport=ASGITransport(app=admin_app_no_flags), # type: ignore[arg-type] + base_url="http://testserver", + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _flag_payload( + key: str = "my-flag", + flag_type: str = "boolean", + enabled: bool = True, + on_value: object = True, + off_value: object = False, +) -> dict: + return { + "key": key, + "name": key.replace("-", " ").title(), + "type": flag_type, + "variations": [ + {"name": "on", "value": on_value}, + {"name": "off", "value": off_value}, + ], + "off_variation": "off", + "fallthrough": "on", + "enabled": enabled, + } + + +def _segment_payload( + key: str, included: list[str] | None = None, excluded: list[str] | None = None +) -> dict: + return { + "key": key, + "name": key.replace("-", " ").title(), + "included": included or [], + "excluded": excluded or [], + "rules": [], + } + + +# --------------------------------------------------------------------------- +# Flags page — GET /flags +# --------------------------------------------------------------------------- + + +class TestFlagsPage: + async def test_flags_page_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + assert resp.status_code == 200 + + async def test_flags_page_html(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + assert "text/html" in resp.headers["content-type"] + + async def test_flags_page_shows_empty_state(self, client: AsyncClient) -> None: + resp = await client.get("/flags") + # Empty flags list renders without error + assert resp.status_code == 200 + + async def test_flags_page_shows_flag_key( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("checkout-flag")) + resp = await client.get("/flags") + assert "checkout-flag" in resp.text + + async def test_flags_page_not_mounted_when_disabled(self, client_no_flags: AsyncClient) -> None: + resp = await client_no_flags.get("/flags") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flags rows partial — GET /flags/rows +# --------------------------------------------------------------------------- + + +class TestFlagsRowsPartial: + async def test_rows_partial_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/flags/rows") + assert resp.status_code == 200 + + async def test_rows_partial_search_filter(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("alpha-flag")) + await client.post("/api/flags", json=_flag_payload("beta-flag")) + resp = await client.get("/flags/rows?q=alpha") + assert "alpha-flag" in resp.text + assert "beta-flag" not in resp.text + + async def test_rows_partial_type_filter(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("bool-flag", "boolean")) + await client.post( + "/api/flags", + json=_flag_payload("str-flag", "string", on_value="hello", off_value=""), + ) + resp = await client.get("/flags/rows?type=boolean") + assert "bool-flag" in resp.text + assert "str-flag" not in resp.text + + async def test_rows_partial_status_enabled(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("on-flag", enabled=True)) + await client.post("/api/flags", json=_flag_payload("off-flag", enabled=False)) + resp = await client.get("/flags/rows?status=enabled") + assert "on-flag" in resp.text + assert "off-flag" not in resp.text + + async def test_rows_partial_status_disabled(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("on-flag", enabled=True)) + await client.post("/api/flags", json=_flag_payload("off-flag", enabled=False)) + resp = await client.get("/flags/rows?status=disabled") + assert "off-flag" in resp.text + assert "on-flag" not in resp.text + + +# --------------------------------------------------------------------------- +# Flag detail page — GET /flags/{key} +# --------------------------------------------------------------------------- + + +class TestFlagDetailPage: + async def test_detail_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("detail-flag")) + resp = await client.get("/flags/detail-flag") + assert resp.status_code == 200 + + async def test_detail_shows_flag_key(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("detail-flag")) + resp = await client.get("/flags/detail-flag") + assert "detail-flag" in resp.text + + async def test_detail_404_for_missing_flag(self, client: AsyncClient) -> None: + resp = await client.get("/flags/nonexistent-flag") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag enable / disable — POST /flags/{key}/enable, /flags/{key}/disable +# --------------------------------------------------------------------------- + + +class TestFlagEnableDisable: + async def test_flag_enable_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=False)) + resp = await client.post("/flags/toggle-flag/enable") + assert resp.status_code == 200 + + async def test_flag_enable_updates_state(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=False)) + await client.post("/flags/toggle-flag/enable") + check = await client.get("/api/flags/toggle-flag") + assert check.json()["enabled"] is True + + async def test_flag_disable_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=True)) + resp = await client.post("/flags/toggle-flag/disable") + assert resp.status_code == 200 + + async def test_flag_disable_updates_state(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("toggle-flag", enabled=True)) + await client.post("/flags/toggle-flag/disable") + check = await client.get("/api/flags/toggle-flag") + assert check.json()["enabled"] is False + + async def test_enable_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post("/flags/ghost-flag/enable") + assert resp.status_code == 404 + + async def test_disable_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post("/flags/ghost-flag/disable") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Flag delete — DELETE /flags/{key} +# --------------------------------------------------------------------------- + + +class TestFlagDelete: + async def test_flag_delete_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("del-flag")) + resp = await client.delete("/flags/del-flag") + assert resp.status_code == 200 + + async def test_flag_delete_removes_flag(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("del-flag")) + await client.delete("/flags/del-flag") + check = await client.get("/api/flags/del-flag") + assert check.status_code == 404 + + async def test_delete_missing_flag_returns_200(self, client: AsyncClient) -> None: + # Dashboard DELETE is idempotent — HTMX removes row; no 404 from UI + resp = await client.delete("/flags/ghost-flag") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Flag create modal — GET /modal/flag/create +# --------------------------------------------------------------------------- + + +class TestFlagCreateModal: + async def test_modal_create_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/modal/flag/create") + assert resp.status_code == 200 + + async def test_modal_create_contains_form(self, client: AsyncClient) -> None: + resp = await client.get("/modal/flag/create") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Flag create form — POST /flags/create +# --------------------------------------------------------------------------- + + +class TestFlagCreateForm: + async def test_create_form_boolean_flag(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/create", + data={"key": "form-bool-flag", "type": "boolean", "name": "Form Bool"}, + ) + assert resp.status_code == 200 + + async def test_create_form_persists_flag(self, client: AsyncClient) -> None: + await client.post( + "/flags/create", + data={"key": "persisted-flag", "type": "boolean", "name": "Persisted"}, + ) + check = await client.get("/api/flags/persisted-flag") + assert check.status_code == 200 + assert check.json()["key"] == "persisted-flag" + + async def test_create_form_string_flag(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/create", + data={ + "key": "str-flag", + "type": "string", + "name": "String Flag", + "default_value": "hello", + }, + ) + assert resp.status_code == 200 + + async def test_create_form_duplicate_key_returns_error(self, client: AsyncClient) -> None: + await client.post( + "/flags/create", data={"key": "dup-flag", "type": "boolean", "name": "Dup"} + ) + resp = await client.post( + "/flags/create", data={"key": "dup-flag", "type": "boolean", "name": "Dup"} + ) + # Should return an error response (409 or HTML with error) + assert resp.status_code in (200, 409) + + +# --------------------------------------------------------------------------- +# Flag eval modal — GET /modal/flag/{key}/eval +# --------------------------------------------------------------------------- + + +class TestFlagEvalModal: + async def test_eval_modal_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag")) + resp = await client.get("/modal/flag/eval-flag/eval") + assert resp.status_code == 200 + + async def test_eval_modal_contains_form(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag")) + resp = await client.get("/modal/flag/eval-flag/eval") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Flag eval form — POST /flags/{key}/eval +# --------------------------------------------------------------------------- + + +class TestFlagEvalForm: + async def test_eval_form_returns_result(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"targeting_key": "user-1"}, + ) + assert resp.status_code == 200 + + async def test_eval_form_triggers_event(self, client: AsyncClient) -> None: + import json as _json + + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-1"}, + ) + assert resp.status_code == 200 + # Eval returns rich result HTML + HX-Trigger header with the result payload + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "shieldEvalDone" in trigger + payload = trigger["shieldEvalDone"] + assert "value" in payload + assert "reason" in payload + assert payload["flagKey"] == "eval-flag" + + async def test_eval_form_shows_result_panel(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-1"}, + ) + assert resp.status_code == 200 + assert "Evaluation Result" in resp.text + assert "FALLTHROUGH" in resp.text or "OFF" in resp.text or "RULE_MATCH" in resp.text + + async def test_eval_form_shows_context_summary(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("eval-flag", enabled=True)) + resp = await client.post( + "/flags/eval-flag/eval", + data={"context_key": "user-99", "kind": "organization", "attributes": "plan=pro"}, + ) + assert resp.status_code == 200 + assert "user-99" in resp.text + assert "organization" in resp.text + assert "plan" in resp.text + + +# --------------------------------------------------------------------------- +# Segments page — GET /segments +# --------------------------------------------------------------------------- + + +class TestSegmentsPage: + async def test_segments_page_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/segments") + assert resp.status_code == 200 + + async def test_segments_page_html(self, client: AsyncClient) -> None: + resp = await client.get("/segments") + assert "text/html" in resp.headers["content-type"] + + async def test_segments_page_not_mounted_when_disabled( + self, client_no_flags: AsyncClient + ) -> None: + resp = await client_no_flags.get("/segments") + assert resp.status_code == 404 + + async def test_segments_page_shows_segment(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("beta-users", included=["u1"])) + resp = await client.get("/segments") + assert "beta-users" in resp.text + + +# --------------------------------------------------------------------------- +# Segments rows partial — GET /segments/rows +# --------------------------------------------------------------------------- + + +class TestSegmentsRowsPartial: + async def test_rows_partial_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/segments/rows") + assert resp.status_code == 200 + + async def test_rows_partial_search_filter(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("alpha-seg")) + await client.post("/api/segments", json=_segment_payload("beta-seg")) + resp = await client.get("/segments/rows?q=alpha") + assert "alpha-seg" in resp.text + assert "beta-seg" not in resp.text + + +# --------------------------------------------------------------------------- +# Segment create modal — GET /modal/segment/create +# --------------------------------------------------------------------------- + + +class TestSegmentCreateModal: + async def test_modal_create_returns_200(self, client: AsyncClient) -> None: + resp = await client.get("/modal/segment/create") + assert resp.status_code == 200 + + async def test_modal_create_contains_form(self, client: AsyncClient) -> None: + resp = await client.get("/modal/segment/create") + assert "form" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# Segment create form — POST /segments/create +# --------------------------------------------------------------------------- + + +class TestSegmentCreateForm: + async def test_create_form_persists_segment(self, client: AsyncClient) -> None: + resp = await client.post( + "/segments/create", + data={"key": "new-seg", "name": "New Seg", "included": "u1\nu2", "excluded": ""}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/new-seg") + assert check.status_code == 200 + assert check.json()["key"] == "new-seg" + + async def test_create_form_creates_empty_segment(self, client: AsyncClient) -> None: + # segment_create_form only sets key+name; membership is edited via save_form + await client.post( + "/segments/create", + data={"key": "inc-seg", "name": "Inc Seg"}, + ) + check = await client.get("/api/segments/inc-seg") + assert check.status_code == 200 + assert check.json()["key"] == "inc-seg" + + +# --------------------------------------------------------------------------- +# Segment detail modal — GET /modal/segment/{key} +# --------------------------------------------------------------------------- + + +class TestSegmentDetailModal: + async def test_detail_modal_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("detail-seg", included=["u1"])) + resp = await client.get("/modal/segment/detail-seg") + assert resp.status_code == 200 + + async def test_detail_modal_shows_key(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("detail-seg", included=["u1"])) + resp = await client.get("/modal/segment/detail-seg") + assert "detail-seg" in resp.text + + +# --------------------------------------------------------------------------- +# Segment save form — POST /segments/{key}/save +# --------------------------------------------------------------------------- + + +class TestSegmentSaveForm: + async def test_save_form_updates_included(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("save-seg", included=["old-user"])) + resp = await client.post( + "/segments/save-seg/save", + data={"included": "new-user1\nnew-user2", "excluded": ""}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/save-seg") + assert "new-user1" in check.json()["included"] + assert "new-user2" in check.json()["included"] + + async def test_save_form_updates_excluded(self, client: AsyncClient) -> None: + await client.post("/api/segments", json=_segment_payload("save-seg2")) + resp = await client.post( + "/segments/save-seg2/save", + data={"included": "", "excluded": "blocked-user"}, + ) + assert resp.status_code == 200 + check = await client.get("/api/segments/save-seg2") + assert "blocked-user" in check.json()["excluded"] + + async def test_save_missing_segment_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/segments/ghost-seg/save", + data={"included": "u1", "excluded": ""}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Segment delete — DELETE /segments/{key} +# --------------------------------------------------------------------------- + + +class TestSegmentDelete: + async def test_delete_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/segments", json={"key": "del-seg", "included": []}) + resp = await client.delete("/segments/del-seg") + assert resp.status_code == 200 + + async def test_delete_removes_segment(self, client: AsyncClient) -> None: + await client.post("/api/segments", json={"key": "del-seg2", "included": []}) + await client.delete("/segments/del-seg2") + check = await client.get("/api/segments/del-seg2") + assert check.status_code == 404 + + async def test_delete_missing_segment_returns_200(self, client: AsyncClient) -> None: + # Dashboard DELETE is idempotent — HTMX removes row; no 404 from UI + resp = await client.delete("/segments/ghost-seg") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# flags_enabled Jinja2 global +# --------------------------------------------------------------------------- + + +class TestFlagsEnabledGlobal: + async def test_index_no_flags_tab_when_disabled(self, client_no_flags: AsyncClient) -> None: + """When enable_flags=False, the main dashboard should not show flag nav links.""" + resp = await client_no_flags.get("/") + assert resp.status_code == 200 + # Flags nav item should not appear + assert "/flags" not in resp.text + + async def test_index_flags_tab_when_enabled(self, client: AsyncClient) -> None: + """When enable_flags=True, the main dashboard should show flag nav links.""" + resp = await client.get("/") + assert resp.status_code == 200 + assert "/flags" in resp.text + + +# --------------------------------------------------------------------------- +# Flag settings save — POST /flags/{key}/settings/save +# --------------------------------------------------------------------------- + + +class TestFlagSettingsSave: + async def test_settings_save_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + resp = await client.post( + "/flags/s-flag/settings/save", + data={"name": "New Name", "description": "Updated desc"}, + ) + assert resp.status_code == 200 + + async def test_settings_save_updates_name( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + await client.post( + "/flags/s-flag/settings/save", + data={"name": "Renamed Flag", "description": ""}, + ) + flag = await engine.get_flag("s-flag") + assert flag.name == "Renamed Flag" + + async def test_settings_save_updates_description( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + await client.post( + "/flags/s-flag/settings/save", + data={"name": "S Flag", "description": "A description"}, + ) + flag = await engine.get_flag("s-flag") + assert flag.description == "A description" + + async def test_settings_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/settings/save", + data={"name": "x", "description": ""}, + ) + assert resp.status_code == 404 + + async def test_settings_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("s-flag")) + resp = await client.post( + "/flags/s-flag/settings/save", + data={"name": "x", "description": ""}, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag variations save — POST /flags/{key}/variations/save +# --------------------------------------------------------------------------- + + +class TestFlagVariationsSave: + async def test_variations_save_returns_200(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "enabled", + "variations[0][value]": "true", + "variations[1][name]": "disabled", + "variations[1][value]": "false", + }, + ) + assert resp.status_code == 200 + + async def test_variations_save_updates_names( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "enabled", + "variations[0][value]": "true", + "variations[1][name]": "disabled", + "variations[1][value]": "false", + }, + ) + flag = await engine.get_flag("v-flag") + names = [v.name for v in flag.variations] + assert "enabled" in names + assert "disabled" in names + + async def test_variations_save_fewer_than_two_returns_400(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={"variations[0][name]": "only", "variations[0][value]": "true"}, + ) + assert resp.status_code == 400 + + async def test_variations_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/variations/save", + data={ + "variations[0][name]": "a", + "variations[0][value]": "true", + "variations[1][name]": "b", + "variations[1][value]": "false", + }, + ) + assert resp.status_code == 404 + + async def test_variations_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("v-flag")) + resp = await client.post( + "/flags/v-flag/variations/save", + data={ + "variations[0][name]": "on", + "variations[0][value]": "true", + "variations[1][name]": "off", + "variations[1][value]": "false", + }, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag targeting save — POST /flags/{key}/targeting/save +# --------------------------------------------------------------------------- + + +class TestFlagTargetingSave: + async def test_targeting_save_off_variation( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert flag.off_variation == "on" + + async def test_targeting_save_fallthrough( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"fallthrough": "off"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert flag.fallthrough == "off" + + async def test_targeting_save_invalid_variation_returns_400(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "nonexistent"}, + ) + assert resp.status_code == 400 + + async def test_targeting_save_with_rule( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={ + "rules[0][id]": "", + "rules[0][description]": "Beta rule", + "rules[0][variation]": "on", + "rules[0][clauses][0][attribute]": "plan", + "rules[0][clauses][0][operator]": "is", + "rules[0][clauses][0][values]": "pro", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" + + async def test_targeting_save_with_segment_rule( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """in_segment clauses should auto-set attribute to 'key' even when omitted.""" + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={ + "rules[0][id]": "", + "rules[0][description]": "Segment rule", + "rules[0][variation]": "on", + # attribute intentionally blank (hidden in dashboard for segment ops) + "rules[0][clauses][0][attribute]": "", + "rules[0][clauses][0][operator]": "in_segment", + "rules[0][clauses][0][values]": "beta-users", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("t-flag") + assert len(flag.rules) == 1 + clause = flag.rules[0].clauses[0] + assert clause.operator == "in_segment" + assert clause.attribute == "key" + assert clause.values == ["beta-users"] + + async def test_targeting_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert resp.status_code == 404 + + async def test_targeting_save_returns_hx_trigger(self, client: AsyncClient) -> None: + await client.post("/api/flags", json=_flag_payload("t-flag")) + resp = await client.post( + "/flags/t-flag/targeting/save", + data={"off_variation": "on"}, + ) + assert "HX-Trigger" in resp.headers + + +# --------------------------------------------------------------------------- +# Flag prerequisites save — POST /flags/{key}/prerequisites/save +# --------------------------------------------------------------------------- + + +class TestFlagPrerequisitesSave: + async def test_prerequisites_save_empty( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST empty form clears prerequisites, returns 200.""" + await client.post("/api/flags", json=_flag_payload("prereq-flag")) + resp = await client.post("/flags/prereq-flag/prerequisites/save", data={}) + assert resp.status_code == 200 + flag = await engine.get_flag("prereq-flag") + assert flag.prerequisites == [] + + async def test_prerequisites_save_adds_prereq( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with prereqs[0][flag_key]=other_flag&prereqs[0][variation]=on saves it, persists.""" + await client.post("/api/flags", json=_flag_payload("main-flag")) + await client.post("/api/flags", json=_flag_payload("other-flag")) + resp = await client.post( + "/flags/main-flag/prerequisites/save", + data={ + "prereqs[0][flag_key]": "other-flag", + "prereqs[0][variation]": "on", + }, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("main-flag") + assert len(flag.prerequisites) == 1 + assert flag.prerequisites[0].flag_key == "other-flag" + assert flag.prerequisites[0].variation == "on" + + async def test_prerequisites_save_circular_returns_400(self, client: AsyncClient) -> None: + """POST where flag_key == current flag's key returns 400.""" + await client.post("/api/flags", json=_flag_payload("circ-flag")) + resp = await client.post( + "/flags/circ-flag/prerequisites/save", + data={ + "prereqs[0][flag_key]": "circ-flag", + "prereqs[0][variation]": "on", + }, + ) + assert resp.status_code == 400 + + async def test_prerequisites_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/prerequisites/save", + data={}, + ) + assert resp.status_code == 404 + + async def test_prerequisites_save_returns_hx_trigger(self, client: AsyncClient) -> None: + """Response has HX-Trigger header with flagPrerequisitesSaved.""" + import json as _json + + await client.post("/api/flags", json=_flag_payload("hxt-flag")) + resp = await client.post("/flags/hxt-flag/prerequisites/save", data={}) + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "flagPrerequisitesSaved" in trigger + + +# --------------------------------------------------------------------------- +# Flag targets save — POST /flags/{key}/targets/save +# --------------------------------------------------------------------------- + + +class TestFlagTargetsSave: + async def test_targets_save_adds_keys(self, client: AsyncClient, engine: ShieldEngine) -> None: + """POST with targets[on]=user_123\\nuser_456, persists correctly.""" + await client.post("/api/flags", json=_flag_payload("tgt-flag")) + resp = await client.post( + "/flags/tgt-flag/targets/save", + data={"targets[on]": "user_123\nuser_456"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-flag") + assert "user_123" in flag.targets.get("on", []) + assert "user_456" in flag.targets.get("on", []) + + async def test_targets_save_clears_targets( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with empty textareas clears targets.""" + await client.post( + "/api/flags", + json={**_flag_payload("tgt-clear-flag"), "targets": {"on": ["old-user"]}}, + ) + resp = await client.post( + "/flags/tgt-clear-flag/targets/save", + data={"targets[on]": "", "targets[off]": ""}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-clear-flag") + assert flag.targets == {} or flag.targets.get("on", []) == [] + + async def test_targets_save_ignores_unknown_variation( + self, client: AsyncClient, engine: ShieldEngine + ) -> None: + """POST with targets[nonexistent]=user_x doesn't save it.""" + await client.post("/api/flags", json=_flag_payload("tgt-unk-flag")) + resp = await client.post( + "/flags/tgt-unk-flag/targets/save", + data={"targets[nonexistent]": "user_x"}, + ) + assert resp.status_code == 200 + flag = await engine.get_flag("tgt-unk-flag") + assert "nonexistent" not in flag.targets + + async def test_targets_save_missing_flag_returns_404(self, client: AsyncClient) -> None: + resp = await client.post( + "/flags/no-flag/targets/save", + data={"targets[on]": "user_1"}, + ) + assert resp.status_code == 404 + + async def test_targets_save_returns_hx_trigger(self, client: AsyncClient) -> None: + """Response has HX-Trigger header with flagTargetsSaved.""" + import json as _json + + await client.post("/api/flags", json=_flag_payload("tgt-hxt-flag")) + resp = await client.post( + "/flags/tgt-hxt-flag/targets/save", + data={"targets[on]": "user_1"}, + ) + assert "HX-Trigger" in resp.headers + trigger = _json.loads(resp.headers["HX-Trigger"]) + assert "flagTargetsSaved" in trigger diff --git a/tests/dashboard/test_routes.py b/tests/dashboard/test_routes.py index 17f9fc5..d1dbf6d 100644 --- a/tests/dashboard/test_routes.py +++ b/tests/dashboard/test_routes.py @@ -320,8 +320,11 @@ async def test_sse_keepalive_when_subscribe_unsupported() -> None: request = Request(scope) response = await r.events(request) - # Patch anyio.sleep to return immediately so the keepalive fires instantly. - with unittest.mock.patch("shield.dashboard.routes.anyio.sleep", return_value=None): + # Patch anyio.sleep to return immediately and simulate connected client. + with ( + unittest.mock.patch("shield.dashboard.routes.anyio.sleep", return_value=None), + unittest.mock.patch.object(request, "is_disconnected", return_value=False), + ): gen = response.body_iterator # type: ignore[union-attr] first_chunk = await asyncio.wait_for(gen.__anext__(), timeout=2.0) # type: ignore[union-attr] diff --git a/tests/fastapi/_helpers.py b/tests/fastapi/_helpers.py new file mode 100644 index 0000000..7617040 --- /dev/null +++ b/tests/fastapi/_helpers.py @@ -0,0 +1,21 @@ +"""Shared test helpers for the FastAPI test suite.""" + +from __future__ import annotations + +import asyncio + +from fastapi import FastAPI + + +async def _trigger_startup(app: FastAPI) -> None: + """Fire all on_startup handlers registered on ``app.router``. + + ``starlette.Router.startup()`` was removed in Starlette 0.41+ in favour + of the lifespan protocol. This helper iterates the handlers directly so + tests remain compatible across all supported Starlette versions. + """ + for handler in app.router.on_startup: + if asyncio.iscoroutinefunction(handler): + await handler() + else: + handler() diff --git a/tests/fastapi/test_acceptance.py b/tests/fastapi/test_acceptance.py index fc2c228..dc276d0 100644 --- a/tests/fastapi/test_acceptance.py +++ b/tests/fastapi/test_acceptance.py @@ -24,6 +24,7 @@ force_active, maintenance, ) +from tests.fastapi._helpers import _trigger_startup @pytest.fixture @@ -59,7 +60,7 @@ async def health(): app.include_router(router) apply_shield_to_openapi(app, engine) - await app.router.startup() + await _trigger_startup(app) return app, engine diff --git a/tests/fastapi/test_deprecated.py b/tests/fastapi/test_deprecated.py index 1aa28fe..9166395 100644 --- a/tests/fastapi/test_deprecated.py +++ b/tests/fastapi/test_deprecated.py @@ -11,6 +11,7 @@ from shield.fastapi.decorators import deprecated from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -82,7 +83,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) # @router.get() registers "GET:/v1/users" (method-specific key) state = await engine.backend.get_state("GET:/v1/users") @@ -106,7 +107,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -124,7 +125,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -142,7 +143,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -160,7 +161,7 @@ async def v1_users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/users") @@ -180,7 +181,7 @@ async def v1_items(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v1/items") @@ -220,7 +221,7 @@ async def v1_orders(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() diff --git a/tests/fastapi/test_global_maintenance.py b/tests/fastapi/test_global_maintenance.py index ed4f36a..59b3ce9 100644 --- a/tests/fastapi/test_global_maintenance.py +++ b/tests/fastapi/test_global_maintenance.py @@ -16,6 +16,7 @@ from shield.fastapi.decorators import disabled, force_active from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _app_with_routes() -> tuple[FastAPI, ShieldEngine]: @@ -115,7 +116,7 @@ async def test_global_maintenance_blocks_normal_routes(): async def test_global_maintenance_respects_force_active_by_default(): """@force_active routes must remain reachable when include_force_active=False.""" app, engine = _app_with_routes() - await app.router.startup() + await _trigger_startup(app) await engine.enable_global_maintenance(reason="System upgrade") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: @@ -127,7 +128,7 @@ async def test_global_maintenance_respects_force_active_by_default(): async def test_global_maintenance_overrides_force_active_when_flag_set(): """When include_force_active=True, even @force_active routes return 503.""" app, engine = _app_with_routes() - await app.router.startup() + await _trigger_startup(app) await engine.enable_global_maintenance(reason="Hard lockdown", include_force_active=True) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: diff --git a/tests/fastapi/test_global_maintenance_docs.py b/tests/fastapi/test_global_maintenance_docs.py index 770b31a..9033b09 100644 --- a/tests/fastapi/test_global_maintenance_docs.py +++ b/tests/fastapi/test_global_maintenance_docs.py @@ -11,6 +11,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi, setup_shield_docs from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build() -> tuple[FastAPI, ShieldEngine, ShieldRouter]: @@ -34,7 +35,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -50,7 +51,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Deploy window") @@ -70,7 +71,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Emergency patch") @@ -98,7 +99,7 @@ async def health(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Upgrade", exempt_paths=["/health"]) @@ -124,7 +125,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Global reason") @@ -142,7 +143,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) await engine.enable_global_maintenance(reason="Temp") diff --git a/tests/fastapi/test_middleware.py b/tests/fastapi/test_middleware.py index 2c4860f..8e807d9 100644 --- a/tests/fastapi/test_middleware.py +++ b/tests/fastapi/test_middleware.py @@ -18,6 +18,7 @@ from shield.fastapi.decorators import disabled, env_only, force_active, maintenance from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -34,7 +35,7 @@ def _include(app: FastAPI, router: ShieldRouter) -> None: async def _startup(app: FastAPI) -> None: - await app.router.startup() + await _trigger_startup(app) # --------------------------------------------------------------------------- diff --git a/tests/fastapi/test_openapi.py b/tests/fastapi/test_openapi.py index 4a1d24e..f4a9460 100644 --- a/tests/fastapi/test_openapi.py +++ b/tests/fastapi/test_openapi.py @@ -10,6 +10,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _make_full_app(env: str = "dev"): @@ -38,7 +39,7 @@ async def new(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -64,7 +65,7 @@ async def health(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -81,7 +82,7 @@ async def debug(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -130,7 +131,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -181,7 +182,7 @@ async def new(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) # First call — /old is disabled and should be hidden. diff --git a/tests/fastapi/test_openapi_maintenance.py b/tests/fastapi/test_openapi_maintenance.py index 73d82e6..fca3f7c 100644 --- a/tests/fastapi/test_openapi_maintenance.py +++ b/tests/fastapi/test_openapi_maintenance.py @@ -12,6 +12,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi, setup_shield_docs from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _build(env: str = "dev") -> tuple[FastAPI, ShieldEngine, ShieldRouter]: @@ -36,7 +37,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -54,7 +55,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -74,7 +75,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -93,7 +94,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) _ = app.openapi() @@ -112,7 +113,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -129,7 +130,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) schema = app.openapi() @@ -153,7 +154,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) @@ -173,7 +174,7 @@ async def payments(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) @@ -212,7 +213,7 @@ async def health(): return {"status": "ok"} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) apply_shield_to_openapi(app, engine) setup_shield_docs(app, engine) diff --git a/tests/fastapi/test_parameterized_routes.py b/tests/fastapi/test_parameterized_routes.py index 105ec79..6373eaa 100644 --- a/tests/fastapi/test_parameterized_routes.py +++ b/tests/fastapi/test_parameterized_routes.py @@ -26,6 +26,7 @@ from shield.fastapi.decorators import disabled, env_only, force_active, maintenance from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup def _make_app(env: str = "dev") -> tuple[FastAPI, ShieldEngine]: @@ -170,7 +171,7 @@ async def invoices(): return {} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/api/invoices") @@ -192,7 +193,7 @@ async def users(): return {"users": []} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v2/users") @@ -218,7 +219,7 @@ async def get_item(item_id: int): return {"item_id": item_id} app.include_router(router) - await app.router.startup() + await _trigger_startup(app) # Both the registered key and the middleware lookup must use the full # template: GET:/api/items/{item_id} diff --git a/tests/fastapi/test_router.py b/tests/fastapi/test_router.py index 0c4366f..450e41f 100644 --- a/tests/fastapi/test_router.py +++ b/tests/fastapi/test_router.py @@ -10,6 +10,7 @@ from shield.core.models import RouteStatus from shield.fastapi.decorators import disabled, env_only, maintenance from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup @pytest.fixture @@ -102,7 +103,7 @@ async def pay(): app.include_router(router) # Trigger the app's startup events directly (equivalent to server startup). - await app.router.startup() + await _trigger_startup(app) state = await engine.backend.get_state("GET:/pay") assert state.status == RouteStatus.MAINTENANCE diff --git a/tests/fastapi/test_startup_scan.py b/tests/fastapi/test_startup_scan.py index a4393cb..63c84b2 100644 --- a/tests/fastapi/test_startup_scan.py +++ b/tests/fastapi/test_startup_scan.py @@ -30,6 +30,7 @@ from shield.fastapi.middleware import ShieldMiddleware from shield.fastapi.openapi import apply_shield_to_openapi from shield.fastapi.router import ShieldRouter +from tests.fastapi._helpers import _trigger_startup # --------------------------------------------------------------------------- # Helper: simulate ASGI lifespan startup @@ -146,7 +147,7 @@ async def orders(): app.include_router(plain_router) # ShieldRouter startup hook fires first (via app.router.startup). - await app.router.startup() + await _trigger_startup(app) # Simulate a runtime state change — engine.enable overrides the decorator. await engine.enable("GET:/payments") diff --git a/tests/sdk/__init__.py b/tests/sdk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sdk/test_flag_provider.py b/tests/sdk/test_flag_provider.py new file mode 100644 index 0000000..d865691 --- /dev/null +++ b/tests/sdk/test_flag_provider.py @@ -0,0 +1,346 @@ +"""Integration tests — ShieldSDK OpenFeature flag sync. + +Covers: +* ShieldServerBackend._listen_sse() handling flag events +* ShieldSDKFlagProvider REST fetch + SSE hot-reload +* ShieldSDK.use_openfeature() integration +""" + +from __future__ import annotations + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient + +from shield.admin.app import ShieldAdmin +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Segment, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bool_flag(key: str = "my-flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name=key.title(), + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _segment(key: str = "beta") -> Segment: + return Segment( + key=key, + name="Beta Users", + included=["user1"], + ) + + +# --------------------------------------------------------------------------- +# ShieldServerBackend — flag SSE event handling +# --------------------------------------------------------------------------- + + +class TestShieldServerBackendFlagSSE: + def _make_backend(self): + from shield.core.backends.server import ShieldServerBackend + + return ShieldServerBackend(server_url="http://shield:9000", app_id="svc") + + async def test_flag_updated_event_updates_cache(self) -> None: + backend = self._make_backend() + flag = _bool_flag("cached-flag") + flag_dict = flag.model_dump(mode="json") + + # Simulate SSE event processing. + event = {"type": "flag_updated", "key": "cached-flag", "flag": flag_dict} + for q in backend._flag_subscribers: + q.put_nowait(event) + + # Directly update cache as _listen_sse would. + backend._flag_cache["cached-flag"] = flag_dict + + flags = await backend.load_all_flags() + assert len(flags) == 1 + assert flags[0]["key"] == "cached-flag" + + async def test_flag_deleted_event_removes_from_cache(self) -> None: + backend = self._make_backend() + flag = _bool_flag("rm-flag") + backend._flag_cache["rm-flag"] = flag.model_dump(mode="json") + + # Simulate deletion. + backend._flag_cache.pop("rm-flag", None) + + flags = await backend.load_all_flags() + assert flags == [] + + async def test_segment_updated_event_updates_cache(self) -> None: + backend = self._make_backend() + seg = _segment("cached-seg") + seg_dict = seg.model_dump(mode="json") + backend._segment_cache["cached-seg"] = seg_dict + + segs = await backend.load_all_segments() + assert len(segs) == 1 + + async def test_subscribe_flag_changes_yields_events(self) -> None: + backend = self._make_backend() + received: list[dict] = [] + + async def _listen() -> None: + async for event in backend.subscribe_flag_changes(): + received.append(event) + break + + task = asyncio.create_task(_listen()) + await asyncio.sleep(0.05) + + # Inject event directly into subscriber queue. + for q in backend._flag_subscribers: + q.put_nowait({"type": "flag_updated", "key": "x", "flag": {}}) + + await task + assert received[0]["type"] == "flag_updated" + + async def test_load_all_flags_returns_cached(self) -> None: + backend = self._make_backend() + flag = _bool_flag("f1") + backend._flag_cache["f1"] = flag.model_dump(mode="json") + result = await backend.load_all_flags() + assert result == [flag.model_dump(mode="json")] + + async def test_load_all_segments_returns_cached(self) -> None: + backend = self._make_backend() + seg = _segment("s1") + backend._segment_cache["s1"] = seg.model_dump(mode="json") + result = await backend.load_all_segments() + assert result == [seg.model_dump(mode="json")] + + +# --------------------------------------------------------------------------- +# ShieldSDKFlagProvider — REST fetch + SSE hot-reload +# --------------------------------------------------------------------------- + + +class TestShieldSDKFlagProvider: + @pytest.fixture + def engine(self) -> ShieldEngine: + return ShieldEngine() + + @pytest.fixture + def admin(self, engine: ShieldEngine): + return ShieldAdmin(engine=engine, enable_flags=True) + + async def test_fetch_from_server_populates_flags(self, admin, engine) -> None: + """Provider fetches flags from /api/flags on initialize().""" + flag = _bool_flag("fetch-flag") + await engine.save_flag(flag) + + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + # Build a backend with ASGI transport pointing at the admin app. + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + sdk_backend._client = AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) + + provider = ShieldSDKFlagProvider(sdk_backend) + await provider._fetch_from_server() + + assert "fetch-flag" in provider._flags + assert provider._flags["fetch-flag"].key == "fetch-flag" + + await sdk_backend._client.aclose() + + async def test_fetch_from_server_populates_segments(self, admin, engine) -> None: + """Provider fetches segments from /api/segments on initialize().""" + seg = _segment("fetch-seg") + await engine.save_segment(seg) + + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + sdk_backend._client = AsyncClient( + transport=ASGITransport(app=admin), + base_url="http://testserver", + ) + + provider = ShieldSDKFlagProvider(sdk_backend) + await provider._fetch_from_server() + + assert "fetch-seg" in provider._segments + + await sdk_backend._client.aclose() + + async def test_watch_sse_hot_reloads_flag(self) -> None: + """Provider _watch_sse() updates _flags when a flag_updated event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + + flag = _bool_flag("hot-flag") + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + # Inject a flag_updated event into the backend's subscriber queue. + for q in sdk_backend._flag_subscribers: + q.put_nowait( + {"type": "flag_updated", "key": "hot-flag", "flag": flag.model_dump(mode="json")} + ) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "hot-flag" in provider._flags + assert provider._flags["hot-flag"].enabled is True + + async def test_watch_sse_removes_deleted_flag(self) -> None: + """Provider _watch_sse() removes flag when flag_deleted event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + flag = _bool_flag("gone-flag") + provider._flags["gone-flag"] = flag + + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + for q in sdk_backend._flag_subscribers: + q.put_nowait({"type": "flag_deleted", "key": "gone-flag"}) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "gone-flag" not in provider._flags + + async def test_watch_sse_hot_reloads_segment(self) -> None: + """Provider _watch_sse() updates _segments when segment_updated event arrives.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + + seg = _segment("hot-seg") + watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + + for q in sdk_backend._flag_subscribers: + q.put_nowait( + { + "type": "segment_updated", + "key": "hot-seg", + "segment": seg.model_dump(mode="json"), + } + ) + + await asyncio.sleep(0.1) + watch_task.cancel() + import contextlib + + with contextlib.suppress(asyncio.CancelledError): + await watch_task + + assert "hot-seg" in provider._segments + + async def test_provider_shutdown_cancels_watch_task(self) -> None: + """shutdown() cancels the SSE watcher without raising.""" + from shield.core.backends.server import ShieldServerBackend + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + sdk_backend = ShieldServerBackend(server_url="http://testserver", app_id="test-svc") + provider = ShieldSDKFlagProvider(sdk_backend) + provider._watch_task = asyncio.create_task(provider._watch_sse()) + await asyncio.sleep(0.05) + provider.shutdown() + assert provider._watch_task is None + + +# --------------------------------------------------------------------------- +# POST /api/flags/{key}/metrics +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# ShieldSDK.use_openfeature() integration +# --------------------------------------------------------------------------- + + +class TestShieldSDKUseOpenFeature: + async def test_use_openfeature_sets_flag_provider(self) -> None: + """use_openfeature() activates ShieldSDKFlagProvider on the engine.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + assert sdk.engine._flag_provider is None + sdk.use_openfeature() + assert sdk.engine._flag_provider is not None + + from shield.sdk.flag_provider import ShieldSDKFlagProvider + + assert isinstance(sdk.engine._flag_provider, ShieldSDKFlagProvider) + + async def test_use_openfeature_enables_flag_client(self) -> None: + """use_openfeature() should also set up the flag_client property.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + sdk.use_openfeature() + assert sdk.engine.flag_client is not None + + async def test_use_openfeature_with_domain(self) -> None: + """use_openfeature(domain=...) uses the given domain name.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK( + server_url="http://shield:9000", + app_id="test-svc", + ) + # Should not raise with a custom domain. + sdk.use_openfeature(domain="payments") + assert sdk.engine._flag_provider is not None + + async def test_use_openfeature_idempotent(self) -> None: + """Calling use_openfeature() twice should not crash.""" + from shield.sdk import ShieldSDK + + sdk = ShieldSDK(server_url="http://shield:9000", app_id="test-svc") + sdk.use_openfeature() + sdk.use_openfeature() # second call — should not raise + assert sdk.engine._flag_provider is not None diff --git a/tests/test_flags_cli.py b/tests/test_flags_cli.py new file mode 100644 index 0000000..1e2fc94 --- /dev/null +++ b/tests/test_flags_cli.py @@ -0,0 +1,975 @@ +"""Tests for the shield flags / shield segments CLI commands. + +Tests are sync (def, not async) — the CLI uses anyio.run() internally +and cannot be nested inside a running pytest-asyncio event loop. + +Pattern: create an in-process ShieldAdmin, inject it via make_client mock, +invoke CLI commands through typer.testing.CliRunner. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import anyio +import httpx +from typer.testing import CliRunner + +from shield.admin.app import ShieldAdmin +from shield.cli.client import ShieldClient +from shield.cli.main import cli as app +from shield.core.engine import ShieldEngine +from shield.core.feature_flags.models import ( + FeatureFlag, + FlagType, + FlagVariation, + Segment, +) + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _seed_engine_with_flags(*flags: FeatureFlag, segments: list = None) -> ShieldEngine: + """Create engine, seed flags and segments synchronously.""" + e = ShieldEngine() + + async def _run() -> None: + for flag in flags: + await e.save_flag(flag) + for seg in segments or []: + await e.save_segment(seg) + + anyio.run(_run) + return e + + +def _make_flag(key: str = "my_flag", enabled: bool = True) -> FeatureFlag: + return FeatureFlag( + key=key, + name="My Flag", + type=FlagType.BOOLEAN, + variations=[ + FlagVariation(name="on", value=True), + FlagVariation(name="off", value=False), + ], + off_variation="off", + fallthrough="on", + enabled=enabled, + ) + + +def _make_segment(key: str = "beta", included: list[str] = None) -> Segment: + return Segment(key=key, name="Beta Users", included=included or []) + + +def _open_client(engine: ShieldEngine) -> ShieldClient: + """ShieldClient backed by in-process ShieldAdmin with flags enabled.""" + admin = ShieldAdmin(engine=engine, enable_flags=True) + return ShieldClient( + base_url="http://testserver", + transport=httpx.ASGITransport(app=admin), + ) + + +def invoke(client: ShieldClient, *args: str): + with patch("shield.cli.main.make_client", return_value=client): + return runner.invoke(app, list(args), catch_exceptions=False) + + +# --------------------------------------------------------------------------- +# shield flags list +# --------------------------------------------------------------------------- + + +class TestFlagsList: + def test_empty_shows_no_flags_message(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "No flags found" in result.output + + def test_shows_flag_row(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "my_flag" in result.output + + def test_shows_multiple_flags(self): + engine = _seed_engine_with_flags(_make_flag("flag_a"), _make_flag("flag_b")) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "flag_a" in result.output + assert "flag_b" in result.output + + def test_filter_by_type(self): + engine = _seed_engine_with_flags(_make_flag("bool_flag")) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--type", "boolean") + assert result.exit_code == 0 + assert "bool_flag" in result.output + + def test_filter_by_type_no_match(self): + engine = _seed_engine_with_flags(_make_flag("bool_flag")) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--type", "string") + assert result.exit_code == 0 + assert "No flags found" in result.output + + def test_filter_status_enabled(self): + engine = _seed_engine_with_flags( + _make_flag("on_flag", enabled=True), _make_flag("off_flag", enabled=False) + ) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--status", "enabled") + assert result.exit_code == 0 + assert "on_flag" in result.output + assert "off_flag" not in result.output + + def test_filter_status_disabled(self): + engine = _seed_engine_with_flags( + _make_flag("on_flag", enabled=True), _make_flag("off_flag", enabled=False) + ) + client = _open_client(engine) + result = invoke(client, "flags", "list", "--status", "disabled") + assert result.exit_code == 0 + assert "off_flag" in result.output + assert "on_flag" not in result.output + + def test_shows_count(self): + engine = _seed_engine_with_flags(_make_flag("a"), _make_flag("b"), _make_flag("c")) + client = _open_client(engine) + result = invoke(client, "flags", "list") + assert result.exit_code == 0 + assert "3 flag" in result.output + + +# --------------------------------------------------------------------------- +# shield flags get +# --------------------------------------------------------------------------- + + +class TestFlagsGet: + def test_shows_flag_details(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "my_flag" in result.output + assert "boolean" in result.output + + def test_shows_variations(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "on" in result.output + assert "off" in result.output + + def test_shows_enabled_status(self): + engine = _seed_engine_with_flags(_make_flag(enabled=True)) + client = _open_client(engine) + result = invoke(client, "flags", "get", "my_flag") + assert result.exit_code == 0 + assert "enabled" in result.output + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "get", "nonexistent") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags create +# --------------------------------------------------------------------------- + + +class TestFlagsCreate: + def test_create_boolean_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "create", "my_flag", "--name", "My Flag") + assert result.exit_code == 0 + assert "created" in result.output.lower() + assert "my_flag" in result.output + + def test_create_string_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "create", "color_flag", "--name", "Color", "--type", "string" + ) + assert result.exit_code == 0 + assert "color_flag" in result.output + + def test_create_persists_flag(self): + engine = ShieldEngine() + client = _open_client(engine) + invoke(client, "flags", "create", "persist_me", "--name", "Persist") + + flags_result = invoke(client, "flags", "list") + assert "persist_me" in flags_result.output + + def test_invalid_type_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "create", "bad_flag", "--name", "Bad", "--type", "invalid_type" + ) + assert result.exit_code != 0 + + def test_create_duplicate_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "create", "my_flag", "--name", "Dupe") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags enable / disable +# --------------------------------------------------------------------------- + + +class TestFlagsEnableDisable: + def test_enable_flag(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + result = invoke(client, "flags", "enable", "my_flag") + assert result.exit_code == 0 + assert "enabled" in result.output.lower() + + def test_disable_flag(self): + engine = _seed_engine_with_flags(_make_flag(enabled=True)) + client = _open_client(engine) + result = invoke(client, "flags", "disable", "my_flag") + assert result.exit_code == 0 + assert "disabled" in result.output.lower() + + def test_enable_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "enable", "nonexistent") + assert result.exit_code != 0 + + def test_disable_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "disable", "nonexistent") + assert result.exit_code != 0 + + def test_enable_then_verify_via_list(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + invoke(client, "flags", "enable", "my_flag") + result = invoke(client, "flags", "list", "--status", "enabled") + assert "my_flag" in result.output + + +# --------------------------------------------------------------------------- +# shield flags delete +# --------------------------------------------------------------------------- + + +class TestFlagsDelete: + def test_delete_with_yes_flag(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "delete", "my_flag", "--yes") + assert result.exit_code == 0 + assert "deleted" in result.output.lower() + + def test_delete_removes_from_list(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "delete", "my_flag", "--yes") + result = invoke(client, "flags", "list") + assert "my_flag" not in result.output + + def test_delete_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "delete", "nonexistent", "--yes") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags eval +# --------------------------------------------------------------------------- + + +class TestFlagsEval: + def test_eval_basic(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "value" in result.output + assert "reason" in result.output + + def test_eval_shows_value(self): + engine = _seed_engine_with_flags(_make_flag()) # fallthrough="on" + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "True" in result.output + + def test_eval_with_attributes(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "eval", + "my_flag", + "--key", + "user_1", + "--attr", + "role=admin", + "--attr", + "plan=pro", + ) + assert result.exit_code == 0 + + def test_eval_invalid_attr_format(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--attr", "not_key_value") + assert result.exit_code != 0 + + def test_eval_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "eval", "nonexistent", "--key", "user_1") + assert result.exit_code != 0 + + def test_eval_disabled_shows_off_value(self): + engine = _seed_engine_with_flags(_make_flag(enabled=False)) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + # Disabled flag → off variation (False) + assert "False" in result.output + + def test_eval_shows_reason(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "eval", "my_flag", "--key", "user_1") + assert result.exit_code == 0 + assert "FALLTHROUGH" in result.output or "OFF" in result.output or "reason" in result.output + + +# --------------------------------------------------------------------------- +# shield segments list +# --------------------------------------------------------------------------- + + +class TestSegmentsList: + def test_empty_shows_message(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "No segments" in result.output + + def test_shows_segment_row(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "beta" in result.output + + def test_shows_count(self): + engine = _seed_engine_with_flags(segments=[_make_segment("a"), _make_segment("b")]) + client = _open_client(engine) + result = invoke(client, "segments", "list") + assert result.exit_code == 0 + assert "2 segment" in result.output + + +# --------------------------------------------------------------------------- +# shield segments get +# --------------------------------------------------------------------------- + + +class TestSegmentsGet: + def test_shows_segment_details(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["u1", "u2"])]) + client = _open_client(engine) + result = invoke(client, "segments", "get", "beta") + assert result.exit_code == 0 + assert "beta" in result.output + + def test_shows_included_members(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["user_1"])]) + client = _open_client(engine) + result = invoke(client, "segments", "get", "beta") + assert result.exit_code == 0 + assert "user_1" in result.output + + def test_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "get", "nonexistent") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments create +# --------------------------------------------------------------------------- + + +class TestSegmentsCreate: + def test_create_segment(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "create", "beta", "--name", "Beta Users") + assert result.exit_code == 0 + assert "beta" in result.output + assert "created" in result.output.lower() + + def test_create_persists_segment(self): + engine = ShieldEngine() + client = _open_client(engine) + invoke(client, "segments", "create", "pro", "--name", "Pro Users") + result = invoke(client, "segments", "list") + assert "pro" in result.output + + def test_create_duplicate_exits_nonzero(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "create", "beta", "--name", "Beta") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments delete +# --------------------------------------------------------------------------- + + +class TestSegmentsDelete: + def test_delete_with_yes(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "segments", "delete", "beta", "--yes") + assert result.exit_code == 0 + assert "deleted" in result.output.lower() + + def test_delete_removes_from_list(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + invoke(client, "segments", "delete", "beta", "--yes") + result = invoke(client, "segments", "list") + assert "beta" not in result.output + + def test_delete_missing_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "segments", "delete", "nonexistent", "--yes") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield segments include / exclude +# --------------------------------------------------------------------------- + + +class TestSegmentsIncludeExclude: + def test_include_adds_keys(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "beta", + "--context-key", + "user_1,user_2", + ) + assert result.exit_code == 0 + assert "2" in result.output # 2 keys added + + # Verify via get + get_result = invoke(client, "segments", "get", "beta") + assert "user_1" in get_result.output + + def test_include_deduplicates(self): + engine = _seed_engine_with_flags(segments=[_make_segment(included=["user_1"])]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "beta", + "--context-key", + "user_1,user_2", + ) + assert result.exit_code == 0 + # Only user_2 is new + assert "1" in result.output + + def test_exclude_adds_keys(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke( + client, + "segments", + "exclude", + "beta", + "--context-key", + "user_99", + ) + assert result.exit_code == 0 + assert "1" in result.output + + def test_include_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "segments", + "include", + "nonexistent", + "--context-key", + "user_1", + ) + assert result.exit_code != 0 + + def test_exclude_missing_segment_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "segments", + "exclude", + "nonexistent", + "--context-key", + "user_1", + ) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield seg alias +# --------------------------------------------------------------------------- + + +class TestSegAlias: + def test_seg_alias_works(self): + engine = _seed_engine_with_flags(segments=[_make_segment()]) + client = _open_client(engine) + result = invoke(client, "seg", "list") + assert result.exit_code == 0 + assert "beta" in result.output + + +# --------------------------------------------------------------------------- +# shield flags edit (PATCH / LaunchDarkly-style mutation) +# --------------------------------------------------------------------------- + + +class TestFlagsEdit: + def test_edit_name(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--name", "Renamed") + assert result.exit_code == 0 + + def test_edit_name_persists(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "edit", "my_flag", "--name", "Renamed") + flag = anyio.run(engine.get_flag, "my_flag") + assert flag.name == "Renamed" + + def test_edit_description(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--description", "A test flag") + assert result.exit_code == 0 + + def test_edit_off_variation(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag", "--off-variation", "on") + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert flag.off_variation == "on" + + def test_edit_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "edit", "no_such_flag", "--name", "x") + assert result.exit_code != 0 + + def test_edit_no_options_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "edit", "my_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags variations (read-only list) +# --------------------------------------------------------------------------- + + +class TestFlagsVariations: + def test_shows_variation_names(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "variations", "my_flag") + assert result.exit_code == 0 + assert "on" in result.output + assert "off" in result.output + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "variations", "no_such_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags targeting (read-only view) +# --------------------------------------------------------------------------- + + +class TestFlagsTargeting: + def test_shows_off_variation(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "targeting", "my_flag") + assert result.exit_code == 0 + assert "off" in result.output # off_variation value + + def test_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "targeting", "no_such_flag") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags add-rule / remove-rule +# --------------------------------------------------------------------------- + + +class TestFlagsAddRule: + def test_add_segment_rule(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "beta-users", + ) + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].operator == "in_segment" + assert flag.rules[0].clauses[0].values == ["beta-users"] + assert flag.rules[0].variation == "on" + + def test_add_attribute_rule(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--attribute", + "plan", + "--operator", + "is", + "--values", + "pro,enterprise", + ) + assert result.exit_code == 0 + flag = anyio.run(engine.get_flag, "my_flag") + assert len(flag.rules) == 1 + assert flag.rules[0].clauses[0].attribute == "plan" + assert flag.rules[0].clauses[0].values == ["pro", "enterprise"] + + def test_add_rule_output_shows_rule_id(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "vip", + ) + assert result.exit_code == 0 + assert "id:" in result.output + + def test_add_rule_no_segment_or_attribute_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "add-rule", "my_flag", "--variation", "on") + assert result.exit_code != 0 + + def test_add_rule_both_segment_and_attribute_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "my_flag", + "--variation", + "on", + "--segment", + "beta", + "--attribute", + "plan", + ) + assert result.exit_code != 0 + + def test_add_rule_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, + "flags", + "add-rule", + "no_such_flag", + "--variation", + "on", + "--segment", + "beta", + ) + assert result.exit_code != 0 + + +class TestFlagsRemoveRule: + def _flag_with_rule(self) -> tuple[FeatureFlag, str]: + from shield.core.feature_flags.models import Operator, RuleClause, TargetingRule + + rule = TargetingRule( + clauses=[RuleClause(attribute="key", operator=Operator.IN_SEGMENT, values=["beta"])], + variation="on", + ) + flag = _make_flag() + flag.rules = [rule] + return flag, rule.id + + def test_remove_rule_by_id(self): + flag, rule_id = self._flag_with_rule() + engine = _seed_engine_with_flags(flag) + client = _open_client(engine) + result = invoke(client, "flags", "remove-rule", "my_flag", "--rule-id", rule_id) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert updated.rules == [] + + def test_remove_rule_unknown_id_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, + "flags", + "remove-rule", + "my_flag", + "--rule-id", + "00000000-0000-0000-0000-000000000000", + ) + assert result.exit_code != 0 + + def test_remove_rule_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "remove-rule", "no_such_flag", "--rule-id", "abc") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags add-prereq / remove-prereq +# --------------------------------------------------------------------------- + + +def _make_two_flags() -> tuple[FeatureFlag, FeatureFlag]: + return _make_flag("flag_a"), _make_flag("flag_b") + + +class TestFlagsAddPrereq: + def test_add_prereq(self): + flag_a, flag_b = _make_two_flags() + engine = _seed_engine_with_flags(flag_a, flag_b) + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "on" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert len(updated.prerequisites) == 1 + assert updated.prerequisites[0].flag_key == "flag_b" + assert updated.prerequisites[0].variation == "on" + + def test_add_prereq_updates_existing(self): + flag_a, flag_b = _make_two_flags() + engine = _seed_engine_with_flags(flag_a, flag_b) + client = _open_client(engine) + invoke(client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "on") + result = invoke( + client, "flags", "add-prereq", "flag_a", "--flag", "flag_b", "--variation", "off" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert len(updated.prerequisites) == 1 + assert updated.prerequisites[0].variation == "off" + + def test_add_prereq_self_reference_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "my_flag", "--flag", "my_flag", "--variation", "on" + ) + assert result.exit_code != 0 + + def test_add_prereq_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "add-prereq", "no_such_flag", "--flag", "other", "--variation", "on" + ) + assert result.exit_code != 0 + + +class TestFlagsRemovePrereq: + def _flag_with_prereq(self) -> FeatureFlag: + from shield.core.feature_flags.models import Prerequisite + + flag = _make_flag("flag_a") + flag.prerequisites = [Prerequisite(flag_key="flag_b", variation="on")] + return flag + + def test_remove_prereq(self): + flag = self._flag_with_prereq() + engine = _seed_engine_with_flags(flag) + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "flag_a", "--flag", "flag_b") + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "flag_a") + assert updated.prerequisites == [] + + def test_remove_prereq_not_found_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "my_flag", "--flag", "nonexistent_prereq") + assert result.exit_code != 0 + + def test_remove_prereq_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke(client, "flags", "remove-prereq", "no_such_flag", "--flag", "other") + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# shield flags target / untarget +# --------------------------------------------------------------------------- + + +class TestFlagsTarget: + def test_target_pins_keys(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "user_1" in updated.targets.get("on", []) + assert "user_2" in updated.targets.get("on", []) + + def test_target_appends_without_duplicates(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + invoke(client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1") + result = invoke( + client, "flags", "target", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert updated.targets["on"].count("user_1") == 1 + assert "user_2" in updated.targets["on"] + + def test_target_unknown_variation_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "target", "my_flag", "--variation", "unknown", "--keys", "user_1" + ) + assert result.exit_code != 0 + + def test_target_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "target", "no_such_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 + + +class TestFlagsUntarget: + def _flag_with_targets(self) -> FeatureFlag: + flag = _make_flag() + flag.targets = {"on": ["user_1", "user_2"]} + return flag + + def test_untarget_removes_keys(self): + engine = _seed_engine_with_flags(self._flag_with_targets()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "user_1" not in updated.targets.get("on", []) + assert "user_2" in updated.targets.get("on", []) + + def test_untarget_removes_variation_when_empty(self): + engine = _seed_engine_with_flags(self._flag_with_targets()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1,user_2" + ) + assert result.exit_code == 0 + updated = anyio.run(engine.get_flag, "my_flag") + assert "on" not in updated.targets + + def test_untarget_no_targets_exits_nonzero(self): + engine = _seed_engine_with_flags(_make_flag()) + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "my_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 + + def test_untarget_missing_flag_exits_nonzero(self): + engine = ShieldEngine() + client = _open_client(engine) + result = invoke( + client, "flags", "untarget", "no_such_flag", "--variation", "on", "--keys", "user_1" + ) + assert result.exit_code != 0 diff --git a/uv.lock b/uv.lock index 37bca9d..22244f2 100644 --- a/uv.lock +++ b/uv.lock @@ -68,6 +68,8 @@ all = [ { name = "httpx" }, { name = "jinja2" }, { name = "limits" }, + { name = "openfeature-sdk" }, + { name = "packaging" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "redis" }, @@ -95,6 +97,8 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, + { name = "openfeature-sdk" }, + { name = "packaging" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -110,6 +114,10 @@ docs = [ fastapi = [ { name = "fastapi" }, ] +flags = [ + { name = "openfeature-sdk" }, + { name = "packaging" }, +] rate-limit = [ { name = "limits" }, ] @@ -157,6 +165,12 @@ requires-dist = [ { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.25" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.25" }, { name = "mypy", marker = "extra == 'dev'" }, + { name = "openfeature-sdk", marker = "extra == 'all'", specifier = ">=0.8" }, + { name = "openfeature-sdk", marker = "extra == 'dev'", specifier = ">=0.8" }, + { name = "openfeature-sdk", marker = "extra == 'flags'", specifier = ">=0.8" }, + { name = "packaging", marker = "extra == 'all'", specifier = ">=23.0" }, + { name = "packaging", marker = "extra == 'dev'", specifier = ">=23.0" }, + { name = "packaging", marker = "extra == 'flags'", specifier = ">=23.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, @@ -405,7 +419,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -414,9 +428,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999 }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407 }, ] [[package]] @@ -466,10 +480,11 @@ wheels = [ [[package]] name = "griffelib" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004 }, + { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377 }, ] [[package]] @@ -939,6 +954,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, ] +[[package]] +name = "openfeature-sdk" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/08/f6698d0614b8703170117b786bd77b7b0a04f3ee00f19fbe9b360d2dee69/openfeature_sdk-0.8.4.tar.gz", hash = "sha256:66abf71f928ec8c0db1111072bb0ef2635dfbd09510f77f4b548e5d0ea0e6c1a", size = 29676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/80/f6532778188c573cc83790b11abccde717d4c1442514e722d6bb6140e55c/openfeature_sdk-0.8.4-py3-none-any.whl", hash = "sha256:805ba090669798fc343ca9fdcbc56ff0f4b57bf6757533f0854d2021192e620a", size = 35986 }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1389,15 +1413,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, ] [[package]]