From a3d82cb5092fdaf7fb74e81d1a5522ade13f58fc Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Tue, 27 Oct 2026 11:52:44 -0400 Subject: [PATCH 1/2] feat(skills)!: rename FDv2SkillStore to LaunchDarklySkillStore The store's public name now says where skill content comes from rather than which protocol version carries it. The base SDKs keep "FDv2" out of their public type names (Python's DataSystemConfig, Go's internal datasystem.NewFDv2), and FDv2 is still Early Access, so a type named after it would date the API. Only the exported class is renamed. The `skills_fdv2` module, its wire-kind constants and the prose describing the FDv2 channel are unchanged, since they describe the protocol itself. The root export has not shipped yet (it arrives with the #210 promotion in the pending release), so no deprecated alias is kept. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 4 +- .../launchdarkly_ai_claude_agents/skills.py | 4 +- packages/client/README.md | 22 +-- packages/client/agents.md | 10 +- .../api-report/launchdarkly_ai_server.txt | 28 +-- .../src/launchdarkly_ai_server/__init__.py | 4 +- .../src/launchdarkly_ai_server/skills.py | 2 +- .../src/launchdarkly_ai_server/skills_core.py | 2 +- .../src/launchdarkly_ai_server/skills_fdv2.py | 30 ++-- .../src/launchdarkly_ai_server/skills_fs.py | 2 +- .../launchdarkly_ai_server/skills_watch.py | 2 +- packages/client/tests/test_public_surface.py | 2 +- packages/client/tests/test_sdk_usage.py | 4 +- packages/client/tests/test_skills.py | 10 +- packages/client/tests/test_skills_fdv2.py | 161 ++++++++++-------- 15 files changed, 151 insertions(+), 136 deletions(-) diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 41bde8a0..670742be 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -137,10 +137,10 @@ asyncio.run(main()) ```python import os -from launchdarkly_ai_server import FDv2SkillStore, config, set_skill_store +from launchdarkly_ai_server import LaunchDarklySkillStore, config, set_skill_store from launchdarkly_ai_claude_agents import create_claude_agents_skills_handler -store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start() +store = LaunchDarklySkillStore(os.environ["LD_SDK_KEY"]).start() store.wait_for_skills(timeout=10) set_skill_store(store) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/skills.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/skills.py index 9d2b56f4..6b8375ee 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/skills.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/skills.py @@ -155,10 +155,10 @@ def create_claude_agents_skills_handler( import os - from launchdarkly_ai_server import FDv2SkillStore, config, set_skill_store + from launchdarkly_ai_server import LaunchDarklySkillStore, config, set_skill_store from launchdarkly_ai_claude_agents import create_claude_agents_skills_handler - store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start() + store = LaunchDarklySkillStore(os.environ["LD_SDK_KEY"]).start() store.wait_for_skills(timeout=10) set_skill_store(store) diff --git a/packages/client/README.md b/packages/client/README.md index 89ba2d07..dd3ef840 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -33,8 +33,8 @@ No code changes are required — `init_client()` detects the packages at runtime | Variable | Required | Description | |---|---|---| | `LD_SDK_KEY` | Yes | LaunchDarkly server-side SDK key | -| `LD_BASE_URI` | No | Override the LaunchDarkly polling base URI (e.g. for staging). `FDv2SkillStore` reads it too when given no URI option | -| `LD_STREAM_URI` | No | Override the streaming URI. `FDv2SkillStore` reads it too when given no URI option | +| `LD_BASE_URI` | No | Override the LaunchDarkly polling base URI (e.g. for staging). `LaunchDarklySkillStore` reads it too when given no URI option | +| `LD_STREAM_URI` | No | Override the streaming URI. `LaunchDarklySkillStore` reads it too when given no URI option | | `LD_EVENTS_URI` | No | Override the events URI | | `LD_SERVICE_NAME` | No | OTel `service.name` resource attribute (default: `python-sdk`) | | `LD_ENVIRONMENT` | No | `deployment.environment` resource attribute attached to telemetry | @@ -670,8 +670,8 @@ worth alerting on too. verification passes, and the usual cause is a bug in a custom `SkillStore` adapter (a stale cache entry, a colliding key, a wrong index lookup) rather than tampering, so neither inflates LaunchDarkly's integrity counter. Both still write this record, so a rule on -`ld.skills.integrity_failure` catches them. If `FDv2SkillStore` is your only store, treat them -like `hash_mismatch`; behind a custom adapter, suspect the adapter first. +`ld.skills.integrity_failure` catches them. If `LaunchDarklySkillStore` is your only store, +treat them like `hash_mismatch`; behind a custom adapter, suspect the adapter first. `get_skill` returns `None` for a `version_mismatch` like any other failure, so this record is the only place it is visible unless you use `get_skill_result`, which reports it as the @@ -725,16 +725,16 @@ be resolved and log a count at WARN. Use `get_skill_result` per key when you nee #### Receiving skills from LaunchDarkly `InMemorySkillStore` is for tests and bring-your-own-content. In production, skill content -arrives through `FDv2SkillStore`, which uses LaunchDarkly's SDK-facing FDv2 delivery channel +arrives through `LaunchDarklySkillStore`, which uses LaunchDarkly's SDK-facing FDv2 delivery channel (the `GET /sdk/poll` and `GET /sdk/stream` endpoints the base SDK's FDv2 data source uses), authenticated with the environment's server-side SDK key. ```python import os -from launchdarkly_ai_server import FDv2SkillStore, set_skill_store, watch_skills +from launchdarkly_ai_server import LaunchDarklySkillStore, set_skill_store, watch_skills -store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start() +store = LaunchDarklySkillStore(os.environ["LD_SDK_KEY"]).start() if not store.wait_for_skills(timeout=10): # No payload arrived. Reconciling now would find an empty store; see below. print(f"skill delivery has not answered yet: {store.failed or 'still waiting'}") @@ -752,9 +752,9 @@ finally: **A reconcile that runs before delivery answers does not prune.** A store still waiting for its first payload looks the same as an environment with no skills, and `write_skills("*")` would otherwise treat that as every skill revoked and delete the files from a previous run. -`FDv2SkillStore` reports readiness through the optional `is_initialized()`; until it is true, a -reconcile reports the retrieval unavailable (`report.ok` is `False`, and the error says why) -and leaves the disk alone. +`LaunchDarklySkillStore` reports readiness through the optional `is_initialized()`; until it +is true, a reconcile reports the retrieval unavailable (`report.ok` is `False`, and the error +says why) and leaves the disk alone. **A 422 means this connection will never be assigned a skill payload, and delivery stops.** Every request declares the payload it wants (`kinds=agent-skill`), and LaunchDarkly answers @@ -914,7 +914,7 @@ that skips verification. | `write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")` | Materialize skills under `root`, returning a `ReconcileReport`. `skills` is a sequence of `Skill` / `SkillReference` / key strings, `"*"`, or a `SkillSource`, which is read once; a source that raises or answers anything malformed is an unavailable retrieval. `prune` removes formerly-managed skills no longer requested. `on_unavailable="raise"` raises instead of reporting when content cannot be retrieved. Raises `ValueError` for an unusable root, a negative or non-finite `timeout`, or an unrecognised `on_unavailable`. `timeout=0` is valid and makes every skill report an error. **Performs synchronous filesystem I/O — see the note below.** | | `SkillStore` | The structural interface content arrives through: `get_object(kind, key, version=None)`, `all_objects(kind)`, optional `is_initialized()`, `add_listener(kind, fn)` / `remove_listener(kind, fn)`. A store without `is_initialized()` is treated as initialized. Both shipped stores deliver only the skill kind, so `add_listener` on any other kind raises. | | `InMemorySkillStore(objects=None)` | A dict-backed store with `put(raw)`, for local development and testing. Holds several versions of a key. | -| `FDv2SkillStore(sdk_key, *, base_uri=…, stream_uri=…, mode="stream", …)` | The delivery transport: a store fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `wait_for_skills(timeout)`, `is_initialized()`, `close()`, `diagnostics`, `failed`; also a context manager. `close()` is **final** — `start()` afterwards raises. `poll_interval`, `read_timeout`, `initial_backoff` and `max_backoff` must be positive and finite, and `initial_backoff` may not exceed `max_backoff`. **Server-side only.** See *Receiving skills from LaunchDarkly* above. | +| `LaunchDarklySkillStore(sdk_key, *, base_uri=…, stream_uri=…, mode="stream", …)` | The delivery transport: a store fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `wait_for_skills(timeout)`, `is_initialized()`, `close()`, `diagnostics`, `failed`; also a context manager. `close()` is **final** — `start()` afterwards raises. `poll_interval`, `read_timeout`, `initial_backoff` and `max_backoff` must be positive and finite, and `initial_backoff` may not exceed `max_backoff`. **Server-side only.** See *Receiving skills from LaunchDarkly* above. | | `watch_skills(skills, root, *, debounce=0.5, on_reconcile=None, …)` | `write_skills` plus a re-reconcile on every delivery change, and, for a `SkillSource`, on every change to one of its `config_keys`, so revocation takes effect once changes have been quiet for `debounce` rather than at the next restart. `watcher.notify()` schedules a reconcile through the same debounce. Returns `(initial report, SkillWatcher)`; close the watcher when done. `debounce` is in **seconds**, non-negative and finite. `on_reconcile` receives each *subsequent* report. One watcher per root. | | `StoreDiagnostics` | What the transport has seen: `payloads_transferred`, `skill_objects_received`, `objects_ignored`, `objects_revoked`, `payloads_ignored`, `hashless_objects`, `connection_failures`, `last_error`. | diff --git a/packages/client/agents.md b/packages/client/agents.md index 499f6435..3eea9a15 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -31,7 +31,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import | `src/launchdarkly_ai_server/types_validation.py` | `parse_ai_config` — validates flag variation shape; `is_valid_skill_key` / `is_valid_skill_version` / `skill_key_rejection_reason` (the canonical key-grammar explanation every layer quotes) | | `src/launchdarkly_ai_server/skills.py` | Agent Skills, retrieval half — `skill_refs`, `get_skill`/`get_skills`/`all_skills`, `InMemorySkillStore`, and the store/telemetry injection points `_set_store` / `_set_emitter_for_testing` | | `src/launchdarkly_ai_server/skills_core.py` | Shared skills internals — the `SkillStore` seam, module state, the telemetry seam and its three recorders, integrity verification, and store resolution. Imported by both `skills.py` and the materialization layer; imports neither | -| `src/launchdarkly_ai_server/skills_fdv2.py` | Agent Skills, delivery transport — the FDv2 protocol, the wire-key/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store interface; imports `skills_core` only, and nothing imports it | +| `src/launchdarkly_ai_server/skills_fdv2.py` | Agent Skills, delivery transport — the FDv2 protocol, the wire-key/`version` translation, the held object set, and `LaunchDarklySkillStore`. Sits **below** the store interface; imports `skills_core` only, and nothing imports it | | `src/launchdarkly_ai_server/skills_watch.py` | Agent Skills, eager re-reconcile — `watch_skills` / `SkillWatcher`, wiring the store's change listener, and for a `SkillSource` the base client's `flag_tracker` listener, to `write_skills`. Sits **above** `skills_fs` and modifies none of it | | `src/launchdarkly_ai_server/skills_source.py` | Agent Skills, config-scoped requested set — `config_skills(key, context)`, which evaluates one AI Config per read and classifies every failure as a raised read, never `[]`. With `skills_watch`, the only skills module that imports `lifecycle`, and it takes only `get_client` | | `src/launchdarkly_ai_server/skills_fs.py` | Agent Skills, materialization half — `write_skills`, request resolution, the manifest format and on-disk filenames, per-skill reconcile, and pruning | @@ -82,7 +82,7 @@ from launchdarkly_ai_server.experimental.evaluations import init_evaluations # Agent Skills from launchdarkly_ai_server import ( - set_skill_store, SkillStore, InMemorySkillStore, FDv2SkillStore, StoreDiagnostics, + set_skill_store, SkillStore, InMemorySkillStore, LaunchDarklySkillStore, StoreDiagnostics, skill_refs, get_skill, get_skill_result, get_skills, all_skills, write_skills, watch_skills, SkillWatcher, SkillSource, config_skills, Skill, SkillReference, SkillOutcome, ReconcileAction, ReconcileReport, @@ -270,7 +270,7 @@ user's files. ### The delivery transport, and the one field that will bite you -`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, +`LaunchDarklySkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, `kinds` + `basis` params, `If-None-Match`/304). It sits below the store interface and produces raw objects in the shape `skills_core.SkillStore` documents; **nothing above that interface knows it exists**. @@ -571,7 +571,7 @@ Store data is **untrusted input**; the transport is not part of the trust bounda directory skips its unlink, drops the manifest entry, and reports `removed` while the revoked skill stays on disk. - **Every numeric option rejects non-finite values, not just negative ones.** - `write_skills`'s `timeout`, `watch_skills`'s `debounce`, and `FDv2SkillStore`'s + `write_skills`'s `timeout`, `watch_skills`'s `debounce`, and `LaunchDarklySkillStore`'s `poll_interval` and `read_timeout` share one rule. `nan` and `inf` both pass a `< 0` check and then mean opposite things downstream: an unbounded vs. an already-expired deadline, a zero vs. a never-closing coalescing window, a poll loop that spins vs. one that stops while @@ -819,7 +819,7 @@ create false confidence. The operator's verification steps are in the README. `timeout` is a monotonic deadline, checked before each retrieval, each write, and each prune; only the final manifest rewrite runs past it, so files already written are never orphaned. Bounded retries are **not** implemented at this layer; retry policy belongs to the delivery -transport (`FDv2SkillStore`'s capped backoff). Why: +transport (`LaunchDarklySkillStore`'s capped backoff). Why: 1. **There is nothing transient to retry.** `SkillStore.get_object` is a synchronous in-process read of already-delivered data, modelled on the LaunchDarkly data-store API. A diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt index f7b2f7dd..b668f5a1 100644 --- a/packages/client/api-report/launchdarkly_ai_server.txt +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -29,20 +29,6 @@ execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, ExecuteStreamDoneEvent = dict[str, Any] ExecuteStreamEvent = dict[str, Any] extract_variation(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async] -FDv2SkillStore [class] - __init__(sdk_key: str, *, base_uri: str | None = None, stream_uri: str | None = None, mode: Mode = 'stream', poll_interval: float = 30.0, read_timeout: float | None = None, initial_backoff: float = 1.0, max_backoff: float = 30.0, _requester: Any = None) - __enter__() -> FDv2SkillStore - __exit__(*_exc: Any) -> None - add_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None - all_objects(kind: str) -> dict[str, dict[str, Any]] - close(timeout: float = 5.0) -> None - diagnostics: StoreDiagnostics [property] - failed: str | None [property] - get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None - is_initialized() -> bool - remove_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None - start() -> FDv2SkillStore - wait_for_skills(timeout: float = 10.0) -> bool get_client() -> Any get_skill(key: str, *, version: int | None = None) -> Skill | None [async] get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome [async] @@ -139,6 +125,20 @@ lang_chain_content_text(content: Any) -> str lang_chain_finish_reasons(source: Any) -> list[str] | None lang_chain_span_messages(messages: list[Any]) -> tuple[str | None, list[SpanMessage]] lang_chain_span_usage(usage: dict[str, Any] | None) -> SpanUsage | None +LaunchDarklySkillStore [class] + __init__(sdk_key: str, *, base_uri: str | None = None, stream_uri: str | None = None, mode: Mode = 'stream', poll_interval: float = 30.0, read_timeout: float | None = None, initial_backoff: float = 1.0, max_backoff: float = 30.0, _requester: Any = None) + __enter__() -> LaunchDarklySkillStore + __exit__(*_exc: Any) -> None + add_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None + all_objects(kind: str) -> dict[str, dict[str, Any]] + close(timeout: float = 5.0) -> None + diagnostics: StoreDiagnostics [property] + failed: str | None [property] + get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None + is_initialized() -> bool + remove_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None + start() -> LaunchDarklySkillStore + wait_for_skills(timeout: float = 10.0) -> bool LDClientInterface [class] close() -> None [async] flush() -> None [async] diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index eb0aa169..f4b0f896 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -66,7 +66,7 @@ ) from .skills import _skill_refs as _skill_refs from .skills_core import SkillStore -from .skills_fdv2 import FDv2SkillStore, StoreDiagnostics +from .skills_fdv2 import LaunchDarklySkillStore, StoreDiagnostics from .skills_fs import ( MANIFEST_FILENAME, MANIFEST_VERSION, @@ -271,7 +271,7 @@ "SkillStore", "InMemorySkillStore", # Agent Skills: LaunchDarkly delivery store and on-change re-reconcile - "FDv2SkillStore", + "LaunchDarklySkillStore", "StoreDiagnostics", "watch_skills", "SkillWatcher", diff --git a/packages/client/src/launchdarkly_ai_server/skills.py b/packages/client/src/launchdarkly_ai_server/skills.py index 2374c96b..60b67842 100644 --- a/packages/client/src/launchdarkly_ai_server/skills.py +++ b/packages/client/src/launchdarkly_ai_server/skills.py @@ -58,7 +58,7 @@ def set_skill_store(store: SkillStore | None) -> None: replacement. Args: - store: ``FDv2SkillStore`` to receive skills from LaunchDarkly, or + store: ``LaunchDarklySkillStore`` to receive skills from LaunchDarkly, or ``InMemorySkillStore`` for local development and tests. Raises: diff --git a/packages/client/src/launchdarkly_ai_server/skills_core.py b/packages/client/src/launchdarkly_ai_server/skills_core.py index 101fcd8c..ff1ee127 100644 --- a/packages/client/src/launchdarkly_ai_server/skills_core.py +++ b/packages/client/src/launchdarkly_ai_server/skills_core.py @@ -95,7 +95,7 @@ NO_STORE_MESSAGE = ( "No skill store is configured, so skill content cannot be retrieved. Configure " "one with set_skill_store(store) from launchdarkly_ai_server " - "— FDv2SkillStore receives content from LaunchDarkly, and InMemorySkillStore is " + "— LaunchDarklySkillStore receives content from LaunchDarkly, and InMemorySkillStore is " "available for local development and testing." ) """What the accessors report when no store is configured. Callers match on diff --git a/packages/client/src/launchdarkly_ai_server/skills_fdv2.py b/packages/client/src/launchdarkly_ai_server/skills_fdv2.py index ecb49fd7..0d9283a3 100644 --- a/packages/client/src/launchdarkly_ai_server/skills_fdv2.py +++ b/packages/client/src/launchdarkly_ai_server/skills_fdv2.py @@ -1,7 +1,7 @@ """ Agent Skills — the FDv2 delivery transport. -``FDv2SkillStore`` is the ``SkillStore`` that receives skills from LaunchDarkly +``LaunchDarklySkillStore`` is the ``SkillStore`` that receives skills from LaunchDarkly over ``GET /sdk/poll`` or ``GET /sdk/stream``, authenticated with the environment's server-side SDK key. It holds raw wire objects and serves them to the accessors; it uses only the standard library. @@ -173,20 +173,20 @@ def _require_server_side_credential(sdk_key: str) -> None: """ if not isinstance(sdk_key, str) or not sdk_key.strip(): raise ValueError( - "FDv2SkillStore requires a LaunchDarkly server-side SDK key " + "LaunchDarklySkillStore requires a LaunchDarkly server-side SDK key " "(sdk-...); none was given." ) key = sdk_key.strip() if key.startswith(_MOBILE_KEY_PREFIX): raise ValueError( - "FDv2SkillStore was given a mobile key (mob-...). Agent Skills are a " + "LaunchDarklySkillStore was given a mobile key (mob-...). Agent Skills are a " "server-side feature: skill content is customer-confidential and is " "never delivered to a mobile or client-side process. Use the " "environment's server-side SDK key (sdk-...)." ) if _CLIENT_SIDE_ID.match(key): raise ValueError( - "FDv2SkillStore was given what looks like a client-side environment " + "LaunchDarklySkillStore was given what looks like a client-side environment " "ID. Agent Skills are a server-side feature: skill content is " "customer-confidential and is never delivered to a client-side " "process. Use the environment's server-side SDK key (sdk-...)." @@ -194,7 +194,7 @@ def _require_server_side_credential(sdk_key: str) -> None: if not key.startswith(_SERVER_KEY_PREFIX): # Warn only: private instances and test doubles may use unprefixed keys. logger.warning( - "The credential given to FDv2SkillStore does not look like a " + "The credential given to LaunchDarklySkillStore does not look like a " "LaunchDarkly server-side SDK key (sdk-...). Skills are delivered " "only to server-side credentials; if this is a client-side or mobile " "credential the connection will be rejected or will deliver nothing." @@ -214,7 +214,7 @@ def _require_https_uri(base_uri: str, option: str = "base_uri") -> None: """ if not isinstance(base_uri, str) or not base_uri.strip(): raise ValueError( - f"FDv2SkillStore requires an https:// URI for {option}; none was given." + f"LaunchDarklySkillStore requires an https:// URI for {option}; none was given." ) parts = urllib.parse.urlsplit(base_uri.strip()) if parts.scheme == "https" and parts.hostname: @@ -223,7 +223,7 @@ def _require_https_uri(base_uri: str, option: str = "base_uri") -> None: return if parts.scheme == "http": raise ValueError( - f"FDv2SkillStore refuses {option} {base_uri!r}: a plain http:// URI " + f"LaunchDarklySkillStore refuses {option} {base_uri!r}: a plain http:// URI " "would send the server-side SDK key in cleartext. Use https:// " "(the defaults are https://sdk.launchdarkly.com for polling and " "https://stream.launchdarkly.com for streaming). Plain http:// is " @@ -231,7 +231,7 @@ def _require_https_uri(base_uri: str, option: str = "base_uri") -> None: "serving a local test double." ) raise ValueError( - f"FDv2SkillStore refuses {option} {base_uri!r}: expected an https:// URI " + f"LaunchDarklySkillStore refuses {option} {base_uri!r}: expected an https:// URI " "with a host, such as https://sdk.launchdarkly.com." ) @@ -1358,14 +1358,14 @@ def _backoff_delay( # --------------------------------------------------------------------------- -class FDv2SkillStore: +class LaunchDarklySkillStore: """ A ``SkillStore`` fed by LaunchDarkly's SDK-facing FDv2 delivery channel. Constructed with the environment's server-side SDK key, started explicitly, and passed to ``set_skill_store``:: - store = FDv2SkillStore(sdk_key=os.environ["LD_SDK_KEY"]) + store = LaunchDarklySkillStore(sdk_key=os.environ["LD_SDK_KEY"]) store.start() if not store.wait_for_skills(timeout=10): ... # no payload yet: see is_initialized @@ -1529,7 +1529,7 @@ def __init__( # -- lifecycle --------------------------------------------------------- - def start(self) -> FDv2SkillStore: + def start(self) -> LaunchDarklySkillStore: """ Starts the delivery thread. Idempotent; returns ``self`` so it chains. @@ -1542,10 +1542,10 @@ def start(self) -> FDv2SkillStore: with self._lock: if self._closed: raise RuntimeError( - "This FDv2SkillStore has been closed, and close() is final: " + "This LaunchDarklySkillStore has been closed, and close() is final: " "delivery cannot be resumed, so a restarted store would " "report itself started and never deliver. Construct a new " - "FDv2SkillStore to resume delivery. Held content is still " + "LaunchDarklySkillStore to resume delivery. Held content is still " "readable from the closed store." ) # Read before the rearm clears ``_failed_reason``: a thread inside @@ -1608,7 +1608,7 @@ def close(self, timeout: float = 5.0) -> None: ): thread.join(timeout=timeout) - def __enter__(self) -> FDv2SkillStore: + def __enter__(self) -> LaunchDarklySkillStore: return self.start() def __exit__(self, *_exc: Any) -> None: @@ -1694,7 +1694,7 @@ def add_listener(self, kind: str, fn: Callable[[dict[str, Any]], Any]) -> None: """ if kind != SKILL_OBJECT_KIND: raise ValueError( - f"FDv2SkillStore notifies only {SKILL_OBJECT_KIND!r} changes, so " + f"LaunchDarklySkillStore notifies only {SKILL_OBJECT_KIND!r} changes, so " f"a listener on {kind!r} would never fire. Register it on " f"{SKILL_OBJECT_KIND!r}." ) diff --git a/packages/client/src/launchdarkly_ai_server/skills_fs.py b/packages/client/src/launchdarkly_ai_server/skills_fs.py index 2c1ed1fa..bc72a087 100644 --- a/packages/client/src/launchdarkly_ai_server/skills_fs.py +++ b/packages/client/src/launchdarkly_ai_server/skills_fs.py @@ -403,7 +403,7 @@ def _available_store(deadline: float, subject: str) -> SkillStore | _RetrievalBl "the skill store has not received its initial data, so " f"{subject} could not be retrieved and nothing on disk was " "changed. Wait for delivery before reconciling: " - "FDv2SkillStore.wait_for_skills(timeout) returns True once the " + "LaunchDarklySkillStore.wait_for_skills(timeout) returns True once the " "first payload has arrived." ) ) diff --git a/packages/client/src/launchdarkly_ai_server/skills_watch.py b/packages/client/src/launchdarkly_ai_server/skills_watch.py index 01fb59e3..e19a9873 100644 --- a/packages/client/src/launchdarkly_ai_server/skills_watch.py +++ b/packages/client/src/launchdarkly_ai_server/skills_watch.py @@ -304,7 +304,7 @@ async def watch_skills( "watch_skills needs a skill store that implements add_listener(kind, " "fn); the configured store does not, so delivery changes cannot be " "observed. Use write_skills for a one-shot reconcile, or configure a " - "store with a delivery transport (FDv2SkillStore)." + "store with a delivery transport (LaunchDarklySkillStore)." ) # A bare ``< 0`` check misses both non-finite values: ``Event.wait(nan)`` # returns immediately (no debouncing) and ``Event.wait(inf)`` never returns. diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index 376060c9..5a175763 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -120,7 +120,7 @@ "set_skill_store", "SkillStore", "InMemorySkillStore", - "FDv2SkillStore", + "LaunchDarklySkillStore", "StoreDiagnostics", "watch_skills", "SkillWatcher", diff --git a/packages/client/tests/test_sdk_usage.py b/packages/client/tests/test_sdk_usage.py index 5e609bd8..73f0e33a 100644 --- a/packages/client/tests/test_sdk_usage.py +++ b/packages/client/tests/test_sdk_usage.py @@ -11,8 +11,8 @@ import launchdarkly_ai_server.sdk_usage as sdk_usage_module from launchdarkly_ai_server import ( SDK_INFO_CONTEXT, - FDv2SkillStore, InMemorySkillStore, + LaunchDarklySkillStore, ProviderHandler, SkillReference, __version__, @@ -660,5 +660,5 @@ async def test_accessors_do_not_report_each_other( async def test_store_setup_is_not_a_helper(client: MagicMock) -> None: set_skill_store(InMemorySkillStore()) - FDv2SkillStore("sdk-test-key") + LaunchDarklySkillStore("sdk-test-key") assert _usage(client) == [] diff --git a/packages/client/tests/test_skills.py b/packages/client/tests/test_skills.py index 89f9b9c2..08912712 100644 --- a/packages/client/tests/test_skills.py +++ b/packages/client/tests/test_skills.py @@ -381,7 +381,7 @@ def test_requires_no_client_or_store(self) -> None: "set_skill_store", "SkillStore", "InMemorySkillStore", - "FDv2SkillStore", + "LaunchDarklySkillStore", "StoreDiagnostics", "watch_skills", "SkillWatcher", @@ -771,9 +771,11 @@ async def test_the_no_store_message_names_the_setter_and_both_stores( await get_skill("a") message = str(reported.value) assert "set_skill_store" in message - assert "FDv2SkillStore" in message + assert "LaunchDarklySkillStore" in message assert "InMemorySkillStore" in message - assert message.index("FDv2SkillStore") < message.index("InMemorySkillStore") + assert message.index("LaunchDarklySkillStore") < message.index( + "InMemorySkillStore" + ) async def test_shutdown_clears_the_store( self, make_raw_skill: Any, mock_ld_client: Any @@ -1520,7 +1522,7 @@ async def test_a_listed_object_is_filed_under_its_own_key( The interface never promised a map key spells a skill key, either: a store holding several versions of one key has reason to spell it - ``key:version``, which is exactly what ``FDv2SkillStore`` does. Pinned + ``key:version``, which is exactly what ``LaunchDarklySkillStore`` does. Pinned because the asymmetry with the pinned path is surprising enough to invite a "fix" that would break every multi-version store. """ diff --git a/packages/client/tests/test_skills_fdv2.py b/packages/client/tests/test_skills_fdv2.py index 4670a954..f2ed212c 100644 --- a/packages/client/tests/test_skills_fdv2.py +++ b/packages/client/tests/test_skills_fdv2.py @@ -40,8 +40,8 @@ import launchdarkly_ai_server.skills_core as skills_core import launchdarkly_ai_server.skills_fdv2 as skills_fdv2 from launchdarkly_ai_server import ( - FDv2SkillStore, InMemorySkillStore, + LaunchDarklySkillStore, all_skills, get_skill, get_skill_result, @@ -398,8 +398,8 @@ def endpoint() -> Any: server.close() -def poll_store(endpoint: Any, **kwargs: Any) -> FDv2SkillStore: - return FDv2SkillStore( +def poll_store(endpoint: Any, **kwargs: Any) -> LaunchDarklySkillStore: + return LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="poll", @@ -1773,7 +1773,7 @@ class TestStreamingAgainstTheEndpoint: def test_a_streamed_payload_lands(self, endpoint: Any) -> None: endpoint.hold_stream_open = True endpoint.queue_stream(full_payload(("put-object", put_skill()))) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", initial_backoff=0.01 ) try: @@ -1786,7 +1786,9 @@ def test_a_streamed_payload_lands(self, endpoint: Any) -> None: def test_the_stream_request_advertises_event_stream(self, endpoint: Any) -> None: endpoint.hold_stream_open = True endpoint.queue_stream(full_payload(("put-object", put_skill()))) - store = FDv2SkillStore(SDK_KEY, base_uri=endpoint.base_uri, mode="stream") + store = LaunchDarklySkillStore( + SDK_KEY, base_uri=endpoint.base_uri, mode="stream" + ) try: store.start() store.wait_for_skills(timeout=5) @@ -1800,7 +1802,9 @@ def test_the_stream_request_declares_the_payload_kind(self, endpoint: Any) -> No against different origins.""" endpoint.hold_stream_open = True endpoint.queue_stream(full_payload(("put-object", put_skill()))) - store = FDv2SkillStore(SDK_KEY, base_uri=endpoint.base_uri, mode="stream") + store = LaunchDarklySkillStore( + SDK_KEY, base_uri=endpoint.base_uri, mode="stream" + ) try: store.start() store.wait_for_skills(timeout=5) @@ -1823,7 +1827,7 @@ def test_a_revocation_after_an_up_to_date_answer_arrives( ("payload-transferred", transferred("basis-2")), ) ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", @@ -1857,7 +1861,9 @@ def test_a_streamed_revocation_arrives_without_a_restart( ("payload-transferred", transferred("basis-2")), ) ) - store = FDv2SkillStore(SDK_KEY, base_uri=endpoint.base_uri, mode="stream") + store = LaunchDarklySkillStore( + SDK_KEY, base_uri=endpoint.base_uri, mode="stream" + ) try: store.start() assert wait_until( @@ -1876,7 +1882,7 @@ def test_a_dropped_stream_reconnects_with_the_basis_it_reached( full_payload(("put-object", put_skill()), state="basis-1") ) endpoint.queue_stream(events(("heart-beat", None))) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", @@ -1898,7 +1904,9 @@ def test_close_returns_promptly_while_a_stream_is_open(self, endpoint: Any) -> N """ endpoint.hold_stream_open = True endpoint.queue_stream(full_payload(("put-object", put_skill()))) - store = FDv2SkillStore(SDK_KEY, base_uri=endpoint.base_uri, mode="stream") + store = LaunchDarklySkillStore( + SDK_KEY, base_uri=endpoint.base_uri, mode="stream" + ) store.start() assert store.wait_for_skills(timeout=5) is True started = time.monotonic() @@ -1910,7 +1918,9 @@ def test_an_interrupted_stream_is_not_reported_as_a_failure( ) -> None: endpoint.hold_stream_open = True endpoint.queue_stream(full_payload(("put-object", put_skill()))) - store = FDv2SkillStore(SDK_KEY, base_uri=endpoint.base_uri, mode="stream") + store = LaunchDarklySkillStore( + SDK_KEY, base_uri=endpoint.base_uri, mode="stream" + ) store.start() store.wait_for_skills(timeout=5) store.close() @@ -1919,7 +1929,7 @@ def test_an_interrupted_stream_is_not_reported_as_a_failure( def test_content_survives_a_reconnect(self, endpoint: Any) -> None: endpoint.queue_stream(full_payload(("put-object", put_skill()))) endpoint.queue_stream(events(("heart-beat", None))) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", initial_backoff=0.01 ) try: @@ -2133,8 +2143,8 @@ def recording(attempt: int, **kwargs: Any) -> float: return attempts -def stream_store(**kwargs: Any) -> FDv2SkillStore: - return FDv2SkillStore( +def stream_store(**kwargs: Any) -> LaunchDarklySkillStore: + return LaunchDarklySkillStore( SDK_KEY, mode="stream", initial_backoff=kwargs.pop("initial_backoff", 0.001), @@ -2252,7 +2262,7 @@ def test_a_restart_resets_the_failure_count(self) -> None: _RecoverableTransportError("x"), _FatalTransportError("401"), ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", initial_backoff=0.001, @@ -2298,7 +2308,7 @@ def poll(self, basis: str | None, etag: str | None) -> Any: _RecoverableTransportError("x"), _FatalTransportError("401"), ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", initial_backoff=0.001, @@ -2634,10 +2644,10 @@ def test_there_is_no_consecutive_failure_option(self) -> None: """ assert ( "max_consecutive_failures" - not in inspect.signature(FDv2SkillStore.__init__).parameters + not in inspect.signature(LaunchDarklySkillStore.__init__).parameters ) with pytest.raises(TypeError): - FDv2SkillStore(SDK_KEY, max_consecutive_failures=3) # type: ignore[call-arg] + LaunchDarklySkillStore(SDK_KEY, max_consecutive_failures=3) # type: ignore[call-arg] @pytest.mark.parametrize("option", ["initial_backoff", "max_backoff"]) @pytest.mark.parametrize("value", [0, 0.0, -5, math.nan, math.inf]) @@ -2648,13 +2658,13 @@ def test_a_backoff_option_must_be_positive_and_finite( retry loop: zero or less reconnects as fast as the network allows.""" kwargs = {"initial_backoff": 1.0, "max_backoff": 30.0, option: value} with pytest.raises(ValueError, match=option): - FDv2SkillStore(SDK_KEY, **kwargs) + LaunchDarklySkillStore(SDK_KEY, **kwargs) def test_the_initial_backoff_may_not_exceed_the_cap(self) -> None: with pytest.raises(ValueError, match="must not exceed"): - FDv2SkillStore(SDK_KEY, initial_backoff=5.0, max_backoff=1.0) + LaunchDarklySkillStore(SDK_KEY, initial_backoff=5.0, max_backoff=1.0) # Equal is a fixed delay, and fine. - FDv2SkillStore(SDK_KEY, initial_backoff=2.0, max_backoff=2.0) + LaunchDarklySkillStore(SDK_KEY, initial_backoff=2.0, max_backoff=2.0) def test_wait_for_skills_runs_to_its_timeout_during_an_outage(self) -> None: """Recoverable failures no longer end delivery, so the store does not @@ -2708,7 +2718,7 @@ def test_the_second_400_still_stops_with_a_recoverable_failure_between( def test_recoverable_failures_are_retried_indefinitely(self) -> None: """Well past ten in a row, the bound this replaces, and still retrying.""" requester = _ScriptedRequester() - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=0.01, @@ -2945,7 +2955,7 @@ class _CorruptedThenClean(_FakeRequester): def __init__(self) -> None: self.bases: list[str | None] = [] self.held_at_reconnect: dict[str, bool] = {} - self.store: FDv2SkillStore | None = None + self.store: LaunchDarklySkillStore | None = None def stream(self, basis: str | None) -> Any: self.bases.append(basis) @@ -3155,7 +3165,7 @@ def test_a_retry_after_header_is_honoured(self) -> None: requester = _ScriptedRequester( _RecoverableTransportError("slow down", retry_after=0.5), ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=10.0, @@ -3188,7 +3198,7 @@ def test_a_retry_after_of_zero_still_waits_the_initial_backoff(self) -> None: requester = _ScriptedRequester( _RecoverableTransportError("slow down", retry_after=0.0), ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=10.0, @@ -3286,7 +3296,7 @@ def test_jitter_never_exceeds_the_cap(self) -> None: def test_a_malformed_polling_envelope_is_recoverable_not_fatal( self, endpoint: Any ) -> None: - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=0.01, @@ -3441,7 +3451,7 @@ def test_an_over_cap_stream_event_abandons_the_payload_in_flight( ("payload-transferred", transferred("basis-2")), ) ) - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", @@ -3613,7 +3623,7 @@ def test_a_redirect_in_stream_mode_stops_delivery( self, endpoint: Any, second_endpoint: Any ) -> None: endpoint.redirect_stream_to = second_endpoint.base_uri + "/sdk/stream" - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=endpoint.base_uri, mode="stream", initial_backoff=0.01 ) with store: @@ -3910,30 +3920,30 @@ async def test_the_payload_version_is_not_resolvable_as_a_skill_version( class TestServerSideOnly: def test_a_mobile_key_is_refused(self) -> None: with pytest.raises(ValueError, match="mobile key"): - FDv2SkillStore("mob-00000000-0000-4000-8000-000000000000") + LaunchDarklySkillStore("mob-00000000-0000-4000-8000-000000000000") def test_a_client_side_environment_id_is_refused(self) -> None: with pytest.raises(ValueError, match="client-side"): - FDv2SkillStore("0123456789abcdef01234567") + LaunchDarklySkillStore("0123456789abcdef01234567") def test_an_empty_credential_is_refused(self) -> None: with pytest.raises(ValueError, match="server-side SDK key"): - FDv2SkillStore(" ") + LaunchDarklySkillStore(" ") def test_a_server_side_key_is_accepted(self) -> None: - assert FDv2SkillStore(SDK_KEY) is not None + assert LaunchDarklySkillStore(SDK_KEY) is not None def test_an_unrecognised_credential_shape_warns_but_is_allowed( self, caplog: Any ) -> None: """Private instances and test doubles issue keys without the public prefix.""" with caplog.at_level("WARNING"): - FDv2SkillStore("my-private-instance-credential") + LaunchDarklySkillStore("my-private-instance-credential") assert any("server-side SDK key" in r.message for r in caplog.records) def test_an_unknown_mode_is_refused(self) -> None: with pytest.raises(ValueError, match="stream"): - FDv2SkillStore(SDK_KEY, mode="mobile") # type: ignore[arg-type] + LaunchDarklySkillStore(SDK_KEY, mode="mobile") # type: ignore[arg-type] class TestBaseUriScheme: @@ -3946,7 +3956,7 @@ class TestBaseUriScheme: def test_a_plain_http_base_uri_is_refused(self) -> None: with pytest.raises(ValueError, match="cleartext") as excinfo: - FDv2SkillStore(SDK_KEY, base_uri="http://sdk.launchdarkly.com") + LaunchDarklySkillStore(SDK_KEY, base_uri="http://sdk.launchdarkly.com") assert "https://" in str(excinfo.value) def test_a_plain_http_base_uri_is_refused_even_with_a_requester_injected( @@ -3954,7 +3964,7 @@ def test_a_plain_http_base_uri_is_refused_even_with_a_requester_injected( ) -> None: """The check is on the store, not on the socket it happens to open.""" with pytest.raises(ValueError, match="cleartext"): - FDv2SkillStore( + LaunchDarklySkillStore( SDK_KEY, base_uri="http://relay.internal:8030", _requester=_FakeRequester(), @@ -3970,12 +3980,12 @@ def test_a_plain_http_base_uri_is_refused_even_with_a_requester_injected( ], ) def test_plain_http_to_a_loopback_host_is_allowed(self, base_uri: str) -> None: - assert FDv2SkillStore(SDK_KEY, base_uri=base_uri) is not None + assert LaunchDarklySkillStore(SDK_KEY, base_uri=base_uri) is not None def test_a_private_address_is_not_loopback(self) -> None: """Only the machine itself is exempt; the LAN is not.""" with pytest.raises(ValueError, match="cleartext"): - FDv2SkillStore(SDK_KEY, base_uri="http://10.0.0.5:8030") + LaunchDarklySkillStore(SDK_KEY, base_uri="http://10.0.0.5:8030") @pytest.mark.parametrize( "base_uri", @@ -3985,16 +3995,19 @@ def test_anything_but_an_https_url_with_a_host_is_refused( self, base_uri: str ) -> None: with pytest.raises(ValueError, match="https://"): - FDv2SkillStore(SDK_KEY, base_uri=base_uri) + LaunchDarklySkillStore(SDK_KEY, base_uri=base_uri) def test_https_is_accepted(self) -> None: - assert FDv2SkillStore(SDK_KEY, base_uri="https://sdk.example.com/") is not None - assert FDv2SkillStore(SDK_KEY) is not None + assert ( + LaunchDarklySkillStore(SDK_KEY, base_uri="https://sdk.example.com/") + is not None + ) + assert LaunchDarklySkillStore(SDK_KEY) is not None def test_a_plain_http_stream_uri_is_refused_by_name(self) -> None: """The streaming host is checked too, and the message names it.""" with pytest.raises(ValueError, match="cleartext") as excinfo: - FDv2SkillStore( + LaunchDarklySkillStore( SDK_KEY, base_uri="https://sdk.example.com", stream_uri="http://stream.example.com", @@ -4013,14 +4026,14 @@ class TestStreamHostDefaults: """ @staticmethod - def _origins(store: FDv2SkillStore) -> tuple[str, str]: + def _origins(store: LaunchDarklySkillStore) -> tuple[str, str]: requester = store._requester return requester._base_uri, requester._stream_uri def test_the_two_defaults_are_different_hosts(self) -> None: assert DEFAULT_BASE_URI == "https://sdk.launchdarkly.com" assert DEFAULT_STREAM_URI == "https://stream.launchdarkly.com" - assert self._origins(FDv2SkillStore(SDK_KEY)) == ( + assert self._origins(LaunchDarklySkillStore(SDK_KEY)) == ( DEFAULT_BASE_URI, DEFAULT_STREAM_URI, ) @@ -4029,12 +4042,12 @@ def test_a_base_uri_alone_serves_both_endpoints(self) -> None: # A relay or a private instance serving both from one host needs one # option, not two. assert self._origins( - FDv2SkillStore(SDK_KEY, base_uri="https://relay.example.com") + LaunchDarklySkillStore(SDK_KEY, base_uri="https://relay.example.com") ) == ("https://relay.example.com", "https://relay.example.com") def test_naming_both_overrides_them_independently(self) -> None: assert self._origins( - FDv2SkillStore( + LaunchDarklySkillStore( SDK_KEY, base_uri="https://sdk.example.com/", stream_uri="https://stream.example.com/", @@ -4043,7 +4056,7 @@ def test_naming_both_overrides_them_independently(self) -> None: def test_a_stream_uri_alone_leaves_polling_on_its_default(self) -> None: assert self._origins( - FDv2SkillStore(SDK_KEY, stream_uri="https://stream.example.com") + LaunchDarklySkillStore(SDK_KEY, stream_uri="https://stream.example.com") ) == (DEFAULT_BASE_URI, "https://stream.example.com") def test_the_stream_request_goes_to_the_stream_host( @@ -4076,7 +4089,7 @@ class TestEndpointsFromTheEnvironment: """ @staticmethod - def _origins(store: FDv2SkillStore) -> tuple[str, str]: + def _origins(store: LaunchDarklySkillStore) -> tuple[str, str]: requester = store._requester return requester._base_uri, requester._stream_uri @@ -4086,7 +4099,7 @@ def test_ld_base_uri_alone_moves_both_endpoints( # The store's own rule for a lone base URI, not ``init_client``'s, # which leaves flag streaming on its default host. monkeypatch.setenv("LD_BASE_URI", "https://sdk.catfood.example.com") - assert self._origins(FDv2SkillStore(SDK_KEY)) == ( + assert self._origins(LaunchDarklySkillStore(SDK_KEY)) == ( "https://sdk.catfood.example.com", "https://sdk.catfood.example.com", ) @@ -4096,7 +4109,7 @@ def test_both_variables_set_each_endpoint( ) -> None: monkeypatch.setenv("LD_BASE_URI", "https://sdk.catfood.example.com") monkeypatch.setenv("LD_STREAM_URI", "https://stream.catfood.example.com/") - assert self._origins(FDv2SkillStore(SDK_KEY)) == ( + assert self._origins(LaunchDarklySkillStore(SDK_KEY)) == ( "https://sdk.catfood.example.com", "https://stream.catfood.example.com", ) @@ -4105,7 +4118,7 @@ def test_ld_stream_uri_alone_leaves_polling_on_its_default( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("LD_STREAM_URI", "https://stream.catfood.example.com") - assert self._origins(FDv2SkillStore(SDK_KEY)) == ( + assert self._origins(LaunchDarklySkillStore(SDK_KEY)) == ( DEFAULT_BASE_URI, "https://stream.catfood.example.com", ) @@ -4118,10 +4131,10 @@ def test_either_option_turns_both_variables_off( monkeypatch.setenv("LD_BASE_URI", "https://sdk.catfood.example.com") monkeypatch.setenv("LD_STREAM_URI", "https://stream.catfood.example.com") assert self._origins( - FDv2SkillStore(SDK_KEY, base_uri="https://relay.example.com") + LaunchDarklySkillStore(SDK_KEY, base_uri="https://relay.example.com") ) == ("https://relay.example.com", "https://relay.example.com") assert self._origins( - FDv2SkillStore(SDK_KEY, stream_uri="https://stream.example.com") + LaunchDarklySkillStore(SDK_KEY, stream_uri="https://stream.example.com") ) == (DEFAULT_BASE_URI, "https://stream.example.com") @pytest.mark.parametrize("blank", ["", " "]) @@ -4130,7 +4143,7 @@ def test_a_blank_value_is_unset( ) -> None: monkeypatch.setenv("LD_BASE_URI", blank) monkeypatch.setenv("LD_STREAM_URI", blank) - assert self._origins(FDv2SkillStore(SDK_KEY)) == ( + assert self._origins(LaunchDarklySkillStore(SDK_KEY)) == ( DEFAULT_BASE_URI, DEFAULT_STREAM_URI, ) @@ -4143,7 +4156,7 @@ def test_a_plain_http_value_is_refused_by_the_variable_name( passed.""" monkeypatch.setenv(name, "http://sdk.catfood.example.com") with pytest.raises(ValueError, match="cleartext") as excinfo: - FDv2SkillStore(SDK_KEY) + LaunchDarklySkillStore(SDK_KEY) message = str(excinfo.value) assert name in message assert "base_uri" not in message @@ -4153,7 +4166,7 @@ def test_the_variables_are_read_once_at_construction( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("LD_BASE_URI", "https://sdk.catfood.example.com") - store = FDv2SkillStore(SDK_KEY) + store = LaunchDarklySkillStore(SDK_KEY) monkeypatch.setenv("LD_BASE_URI", "https://sdk.elsewhere.example.com") monkeypatch.setenv("LD_STREAM_URI", "https://stream.elsewhere.example.com") assert self._origins(store) == ( @@ -4165,7 +4178,7 @@ def test_the_module_defaults_keep_the_production_hosts( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("LD_BASE_URI", "https://sdk.catfood.example.com") - FDv2SkillStore(SDK_KEY) + LaunchDarklySkillStore(SDK_KEY) assert DEFAULT_BASE_URI == "https://sdk.launchdarkly.com" assert DEFAULT_STREAM_URI == "https://stream.launchdarkly.com" @@ -4177,7 +4190,7 @@ def test_the_module_defaults_keep_the_production_hosts( class TestWatchSkillsOverTheTransport: """ - ``watch_skills`` against a live ``FDv2SkillStore``. The watcher's own + ``watch_skills`` against a live ``LaunchDarklySkillStore``. The watcher's own behaviour — debounce, refusal of a store without ``add_listener``, detaching on close — is covered in ``test_skills_watch.py`` against the in-memory store; these are the cases that only mean something with a transport @@ -4364,7 +4377,7 @@ def test_a_closed_store_does_not_restart(self, endpoint: Any) -> None: with pytest.raises(RuntimeError, match="close\\(\\) is final") as excinfo: store.start() # The remedy is in the message, not only in the docs. - assert "Construct a new FDv2SkillStore" in str(excinfo.value) + assert "Construct a new LaunchDarklySkillStore" in str(excinfo.value) def test_a_store_closed_before_it_started_also_refuses_to_start( self, endpoint: Any @@ -4412,7 +4425,7 @@ def test_a_closed_store_still_answers_from_what_it_received( assert store.get_object(SKILL_OBJECT_KIND, "pdf-extraction") is not None def test_wait_for_skills_times_out_rather_than_hanging(self) -> None: - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=60, _requester=_ScriptedRequester() ) try: @@ -4421,7 +4434,7 @@ def test_wait_for_skills_times_out_rather_than_hanging(self) -> None: store.close() def test_the_store_satisfies_the_interface_before_it_starts(self) -> None: - store = FDv2SkillStore(SDK_KEY) + store = LaunchDarklySkillStore(SDK_KEY) assert store.get_object(SKILL_OBJECT_KIND, "anything") is None assert store.all_objects(SKILL_OBJECT_KIND) == {} @@ -4485,10 +4498,10 @@ def test_it_adds_no_dependency(self) -> None: def test_nothing_in_the_feature_imports_it(self) -> None: """Only the package root names it, to re-export - ``FDv2SkillStore`` and ``StoreDiagnostics``. + ``LaunchDarklySkillStore`` and ``StoreDiagnostics``. That re-export is the published surface rather than a dependency: it is - what makes ``FDv2SkillStore`` reachable without importing the + what makes ``LaunchDarklySkillStore`` reachable without importing the implementation module. A *feature* module importing the transport would be the layering inverting, and the accessors would start being able to tell which store answered them. @@ -4718,7 +4731,7 @@ def test_a_stream_against_an_unresponsive_host_fails_within_read_timeout( def test_the_store_reports_the_timeout_and_keeps_going( self, black_hole: Any ) -> None: - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=black_hole.base_uri, mode="poll", @@ -4738,20 +4751,20 @@ def test_the_store_reports_the_timeout_and_keeps_going( def test_the_default_bound_depends_on_the_mode(self) -> None: assert DEFAULT_POLL_TIMEOUT == 10.0 assert DEFAULT_STREAM_READ_TIMEOUT == 300.0 - polling = FDv2SkillStore(SDK_KEY, mode="poll") - streaming = FDv2SkillStore(SDK_KEY, mode="stream") + polling = LaunchDarklySkillStore(SDK_KEY, mode="poll") + streaming = LaunchDarklySkillStore(SDK_KEY, mode="stream") assert polling._requester._read_timeout == DEFAULT_POLL_TIMEOUT assert streaming._requester._read_timeout == DEFAULT_STREAM_READ_TIMEOUT @pytest.mark.parametrize("mode", ["poll", "stream"]) def test_an_explicit_read_timeout_overrides_the_default(self, mode: Any) -> None: - store = FDv2SkillStore(SDK_KEY, mode=mode, read_timeout=42.0) + store = LaunchDarklySkillStore(SDK_KEY, mode=mode, read_timeout=42.0) assert store._requester._read_timeout == 42.0 @pytest.mark.parametrize("value", [0.0, -1.0, float("inf"), float("nan")]) def test_a_non_positive_read_timeout_is_rejected(self, value: float) -> None: with pytest.raises(ValueError, match="read_timeout"): - FDv2SkillStore(SDK_KEY, read_timeout=value) + LaunchDarklySkillStore(SDK_KEY, read_timeout=value) @pytest.mark.parametrize( "value", @@ -4769,18 +4782,18 @@ def test_a_non_positive_poll_interval_is_rejected(self, value: float) -> None: never again while reporting itself healthy. """ with pytest.raises(ValueError, match="poll_interval"): - FDv2SkillStore(SDK_KEY, mode="poll", poll_interval=value) + LaunchDarklySkillStore(SDK_KEY, mode="poll", poll_interval=value) def test_a_finite_positive_poll_interval_is_accepted(self) -> None: """Positive control, so the guard above cannot reject every value.""" - store = FDv2SkillStore(SDK_KEY, mode="poll", poll_interval=0.5) + store = LaunchDarklySkillStore(SDK_KEY, mode="poll", poll_interval=0.5) assert store.failed is None def test_there_is_no_separate_connect_timeout(self) -> None: # ``urllib`` cannot bound the connect separately from the reads, so the # constructor does not offer a parameter that would only pretend to. with pytest.raises(TypeError): - FDv2SkillStore(SDK_KEY, connect_timeout=2.0) # type: ignore[call-arg] + LaunchDarklySkillStore(SDK_KEY, connect_timeout=2.0) # type: ignore[call-arg] class TestWaitingForSkills: @@ -4920,7 +4933,7 @@ def poll_until_interrupted() -> None: def test_close_during_a_stalled_poll_returns_promptly( self, stalled_body: Any ) -> None: - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=stalled_body.base_uri, mode="poll", @@ -4941,7 +4954,7 @@ def test_a_poll_we_interrupted_is_not_a_delivery_failure( # Our own shutdown is not an outage: counting it would spend a retry # from the bounded budget and leave a misleading ``last_error`` behind # on a store whose content is still perfectly good. - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, base_uri=stalled_body.base_uri, mode="poll", @@ -4963,7 +4976,7 @@ def test_a_close_that_timed_out_is_still_final(self) -> None: # and a ``start`` that adopted the dying thread would leave a store # reporting itself started and never delivering. Raising says so. requester = _SlowPollRequester() - store = FDv2SkillStore( + store = LaunchDarklySkillStore( SDK_KEY, mode="poll", poll_interval=0.01, _requester=requester ) try: From 5ff2b493030b55f0bb02c86a72e74885b8caa288 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Tue, 27 Oct 2026 14:50:25 -0400 Subject: [PATCH 2/2] feat(skills): keep FDv2SkillStore as a deprecated alias launchdarkly-ai-server 0.5.0 (#206) shipped FDv2SkillStore from the package root, so the old name stays importable until the next major release, matching the JS aliases. Co-Authored-By: Claude Opus 5.5 --- packages/client/api-report/launchdarkly_ai_server.txt | 1 + packages/client/src/launchdarkly_ai_server/__init__.py | 4 ++++ packages/client/tests/test_public_surface.py | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt index b668f5a1..4ed2c00e 100644 --- a/packages/client/api-report/launchdarkly_ai_server.txt +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -29,6 +29,7 @@ execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, ExecuteStreamDoneEvent = dict[str, Any] ExecuteStreamEvent = dict[str, Any] extract_variation(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async] +FDv2SkillStore = LaunchDarklySkillStore get_client() -> Any get_skill(key: str, *, version: int | None = None) -> Skill | None [async] get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome [async] diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index f4b0f896..f69d4e4b 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -149,6 +149,9 @@ ) from .utils import _create_handler as _create_handler +# Deprecated: the 0.5.0 name. Removed in the next major release. +FDv2SkillStore = LaunchDarklySkillStore + __all__ = [ # noqa: RUF022 # types "NATIVE_TOOL_KEY", @@ -272,6 +275,7 @@ "InMemorySkillStore", # Agent Skills: LaunchDarkly delivery store and on-change re-reconcile "LaunchDarklySkillStore", + "FDv2SkillStore", "StoreDiagnostics", "watch_skills", "SkillWatcher", diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index 5a175763..bf106ef8 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -121,6 +121,7 @@ "SkillStore", "InMemorySkillStore", "LaunchDarklySkillStore", + "FDv2SkillStore", "StoreDiagnostics", "watch_skills", "SkillWatcher", @@ -154,3 +155,9 @@ def test_root_surface_is_exactly_the_snapshot() -> None: assert len(package.__all__) == len(ROOT_SURFACE), "duplicate name in __all__" for name in ROOT_SURFACE: assert hasattr(package, name), name + + +def test_deprecated_fdv2_skill_store_name_is_the_same_class() -> None: + from launchdarkly_ai_server import FDv2SkillStore, LaunchDarklySkillStore + + assert FDv2SkillStore is LaunchDarklySkillStore