-
Notifications
You must be signed in to change notification settings - Fork 0
feat: support sdk key and direct config initialization #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,18 @@ | ||
| """Stable public import boundary for the Convert Python SDK.""" | ||
|
|
||
| from .config import SDKConfig, TransportConfig | ||
| from .context import Context | ||
| from .core import Core | ||
| from .errors import ConfigLoadError, ConfigValidationError, InitializationError | ||
| from .version import __version__ | ||
|
|
||
| __all__ = ["Context", "Core", "__version__"] | ||
| __all__ = [ | ||
| "ConfigLoadError", | ||
| "ConfigValidationError", | ||
| "Context", | ||
| "Core", | ||
| "InitializationError", | ||
| "SDKConfig", | ||
| "TransportConfig", | ||
| "__version__", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Concrete adapters for SDK ports.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Transport adapter implementations.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| """HTTPX-backed config transport adapter.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Mapping, Optional | ||
|
|
||
| import httpx | ||
|
|
||
| from ...ports.transport import ConfigRequest | ||
|
|
||
|
|
||
| class HttpxTransport: | ||
| """Sync-first transport adapter for Convert config fetches.""" | ||
|
|
||
| def __init__(self, client: Optional[httpx.Client] = None) -> None: | ||
| self._client = client | ||
|
|
||
| def fetch_config(self, request: ConfigRequest) -> Mapping[str, Any]: | ||
| headers = {"Accept": "application/json"} | ||
| headers.update(dict(request.transport.headers)) | ||
| if request.sdk_key_secret: | ||
| headers["Authorization"] = f"Bearer {request.sdk_key_secret}" | ||
|
|
||
| params = {} | ||
| if request.environment: | ||
| params["environment"] = request.environment | ||
|
|
||
| url = ( | ||
| f"{request.transport.config_endpoint.rstrip('/')}/config/{request.sdk_key}" | ||
| ) | ||
|
|
||
| owns_client = self._client is None | ||
| client = self._client or httpx.Client( | ||
| timeout=request.transport.timeout_seconds, | ||
| verify=request.transport.verify_tls, | ||
| ) | ||
| try: | ||
| response = client.get(url, params=params, headers=headers) | ||
| response.raise_for_status() | ||
| payload = response.json() | ||
| finally: | ||
| if owns_client: | ||
| client.close() | ||
|
|
||
| if not isinstance(payload, dict): | ||
| raise TypeError("Config endpoint returned a non-object JSON payload") | ||
|
|
||
| return payload |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """Public configuration types for SDK initialization.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from typing import Any, Mapping, Optional | ||
|
|
||
|
|
||
| DEFAULT_CONFIG_ENDPOINT = "https://cdn-4.convertexperiments.com/api/v1" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class TransportConfig: | ||
| """Network configuration for config-fetch transport behavior.""" | ||
|
|
||
| config_endpoint: str = DEFAULT_CONFIG_ENDPOINT | ||
| headers: Mapping[str, str] = field(default_factory=dict) | ||
| timeout_seconds: float = 5.0 | ||
| verify_tls: bool = True | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class SDKConfig: | ||
| """Pythonic SDK initialization config.""" | ||
|
|
||
| environment: Optional[str] = None | ||
| sdk_key: Optional[str] = None | ||
| sdk_key_secret: Optional[str] = None | ||
| config_data: Optional[Mapping[str, Any]] = None | ||
| transport: TransportConfig = field(default_factory=TransportConfig) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Config loading helpers.""" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||||||||||||||||||||||
| """Config snapshot loading orchestration.""" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| from .normalizer import build_snapshot | ||||||||||||||||||||||||||||
| from .validators import validate_config_data, validate_sdk_config | ||||||||||||||||||||||||||||
| from ..config import SDKConfig | ||||||||||||||||||||||||||||
| from ..domain.config_snapshot import ConfigSnapshot | ||||||||||||||||||||||||||||
| from ..errors import ConfigLoadError | ||||||||||||||||||||||||||||
| from ..ports.transport import ConfigRequest, Transport | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def load_config_snapshot(config: SDKConfig, transport: Transport) -> ConfigSnapshot: | ||||||||||||||||||||||||||||
| validate_sdk_config(config) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if config.config_data is not None: | ||||||||||||||||||||||||||||
| validate_config_data(config.config_data) | ||||||||||||||||||||||||||||
| return build_snapshot(config.config_data) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| request = ConfigRequest( | ||||||||||||||||||||||||||||
| sdk_key=config.sdk_key or "", | ||||||||||||||||||||||||||||
| sdk_key_secret=config.sdk_key_secret, | ||||||||||||||||||||||||||||
| environment=config.environment, | ||||||||||||||||||||||||||||
| transport=config.transport, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While
Suggested change
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| config_data = transport.fetch_config(request) | ||||||||||||||||||||||||||||
| except Exception as exc: # noqa: BLE001 | ||||||||||||||||||||||||||||
| raise ConfigLoadError( | ||||||||||||||||||||||||||||
| f"Config fetch failed for sdk_key '{config.sdk_key}'" | ||||||||||||||||||||||||||||
| ) from exc | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| validate_config_data(config_data) | ||||||||||||||||||||||||||||
| return build_snapshot(config_data) | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| """Boundary normalization helpers for config ingestion.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Dict, Mapping | ||
|
|
||
| from ..domain.config_snapshot import ConfigSnapshot | ||
|
|
||
|
|
||
| def normalize_config_data(config_data: Mapping[str, Any]) -> Mapping[str, Any]: | ||
| normalized: Dict[str, Any] = dict(config_data) | ||
| normalized.setdefault("experiences", ()) | ||
| normalized.setdefault("features", ()) | ||
| normalized.setdefault("goals", ()) | ||
| normalized.setdefault("audiences", ()) | ||
| return normalized | ||
|
|
||
|
|
||
| def build_snapshot(config_data: Mapping[str, Any]) -> ConfigSnapshot: | ||
| return ConfigSnapshot.from_config_data(normalize_config_data(config_data)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Validation helpers for SDK initialization and config ingestion.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Mapping | ||
|
|
||
| from ..config import SDKConfig | ||
| from ..errors import ConfigValidationError | ||
|
|
||
|
|
||
| def validate_sdk_config(config: SDKConfig) -> None: | ||
| has_sdk_key = bool(config.sdk_key) | ||
| has_config_data = config.config_data is not None | ||
|
|
||
| if has_sdk_key == has_config_data: | ||
| raise ConfigValidationError( | ||
| "Provide exactly one of sdk_key or config_data when initializing Core" | ||
| ) | ||
|
|
||
| if config.sdk_key_secret and not config.sdk_key: | ||
| raise ConfigValidationError("sdk_key_secret requires sdk_key initialization") | ||
|
|
||
| if has_sdk_key and not config.transport.config_endpoint.startswith("https://"): | ||
| raise ConfigValidationError( | ||
| "config_endpoint must use HTTPS for sdk_key initialization" | ||
| ) | ||
|
|
||
| if has_config_data: | ||
| validate_config_data(config.config_data) | ||
|
Comment on lines
+28
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
|
|
||
| def validate_config_data(config_data: Mapping[str, Any] | None) -> None: | ||
| if config_data is None or not isinstance(config_data, Mapping): | ||
| raise ConfigValidationError("config_data must be a mapping") | ||
|
|
||
| project = config_data.get("project") | ||
| if not isinstance(project, Mapping): | ||
| raise ConfigValidationError("config_data must include a project mapping") | ||
|
|
||
| project_id = project.get("id") | ||
| if project_id in (None, ""): | ||
| raise ConfigValidationError("config_data.project.id is required") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,59 @@ | ||
| """Public SDK entry point placeholder for future initialization work.""" | ||
| """Public SDK entry point for initialization and readiness state.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Optional | ||
|
|
||
| from .adapters.transport.httpx_transport import HttpxTransport | ||
| from .config import SDKConfig | ||
| from .config_loader.loader import load_config_snapshot | ||
| from .domain.config_snapshot import ConfigSnapshot | ||
| from .ports.transport import Transport | ||
|
|
||
| class Core: | ||
| """Stable root export reserved for SDK initialization and context creation.""" | ||
| """Stable root export for SDK initialization and config access.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| config: SDKConfig, | ||
| transport: Optional[Transport] = None, | ||
| ) -> None: | ||
| self._config = config | ||
| self._snapshot: Optional[ConfigSnapshot] = None | ||
| self._transport = transport | ||
| self._initialize() | ||
|
|
||
| def _initialize(self) -> None: | ||
| self._snapshot = load_config_snapshot( | ||
| self._config, | ||
| transport=self._transport or HttpxTransport(), | ||
| ) | ||
|
|
||
| @property | ||
| def config(self) -> SDKConfig: | ||
| """Return the initialization config used for the SDK instance.""" | ||
|
|
||
| return self._config | ||
|
|
||
| @property | ||
| def is_ready(self) -> bool: | ||
| """Return whether the SDK has a current immutable config snapshot.""" | ||
|
|
||
| return self._snapshot is not None | ||
|
|
||
| @property | ||
| def snapshot(self) -> ConfigSnapshot: | ||
| """Expose the current immutable config snapshot.""" | ||
|
|
||
| if self._snapshot is None: | ||
| raise RuntimeError("Core is not ready") | ||
| return self._snapshot | ||
|
|
||
| @property | ||
| def current_snapshot(self) -> ConfigSnapshot: | ||
| """Alias for the current immutable config snapshot.""" | ||
|
|
||
| return self.snapshot | ||
|
|
||
| def __repr__(self) -> str: | ||
| return "Core()" | ||
| return f"Core(is_ready={self.is_ready})" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Internal domain models for the Convert Python SDK.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example for initializing with an SDK key is helpful. To make it more robust and production-ready for users, consider demonstrating how to handle potential initialization failures. Since this involves a network request, it could fail and raise an
InitializationError. Wrapping the call toCore()in atry...exceptblock would be a great addition.For example: