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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,36 @@ UV_CACHE_DIR=/tmp/uv-cache uv build
```python
from convert_sdk import Context, Core, __version__
```

### Initialization

Initialize with direct config data:

```python
from convert_sdk import Core, SDKConfig

project_config = {
"account_id": "1001",
"project": {"id": "2002", "name": "Demo"},
}

core = Core(SDKConfig(config_data=project_config))
assert core.is_ready
```

Initialize with an SDK key:

```python
from convert_sdk import Core, SDKConfig, TransportConfig

core = Core(
SDKConfig(
sdk_key="1001/2002",
environment="staging",
transport=TransportConfig(
config_endpoint="https://cdn-4.convertexperiments.com/api/v1",
),
)
)
assert core.is_ready
Comment on lines +48 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 to Core() in a try...except block would be a great addition.

For example:

from convert_sdk import Core, SDKConfig, TransportConfig, InitializationError

try:
    core = Core(
        SDKConfig(
            sdk_key="1001/2002",
            # ...
        )
    )
    assert core.is_ready
    print("SDK initialized successfully!")
except InitializationError as e:
    print(f"SDK initialization failed: {e}")

```
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ authors = [
{ name = "Usman Abbas", email = "usman.abbas7@gmail.com" }
]
requires-python = ">=3.9"
dependencies = []
dependencies = [
"httpx>=0.28,<0.29",
]

[dependency-groups]
dev = [
Expand Down
13 changes: 12 additions & 1 deletion src/convert_sdk/__init__.py
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__",
]
1 change: 1 addition & 0 deletions src/convert_sdk/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Concrete adapters for SDK ports."""
1 change: 1 addition & 0 deletions src/convert_sdk/adapters/transport/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Transport adapter implementations."""
48 changes: 48 additions & 0 deletions src/convert_sdk/adapters/transport/httpx_transport.py
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
30 changes: 30 additions & 0 deletions src/convert_sdk/config.py
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)
1 change: 1 addition & 0 deletions src/convert_sdk/config_loader/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Config loading helpers."""
35 changes: 35 additions & 0 deletions src/convert_sdk/config_loader/loader.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While config.sdk_key or "" works to satisfy the type checker, it's less clear than an explicit assertion. Since validate_sdk_config is called just before this, we can be sure that config.sdk_key is not None. Using an assert makes this assumption explicit, which improves readability and helps static analysis tools.

Suggested change
request = ConfigRequest(
sdk_key=config.sdk_key or "",
sdk_key_secret=config.sdk_key_secret,
environment=config.environment,
transport=config.transport,
)
assert config.sdk_key is not None
request = ConfigRequest(
sdk_key=config.sdk_key,
sdk_key_secret=config.sdk_key_secret,
environment=config.environment,
transport=config.transport,
)


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)
20 changes: 20 additions & 0 deletions src/convert_sdk/config_loader/normalizer.py
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))
42 changes: 42 additions & 0 deletions src/convert_sdk/config_loader/validators.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The load_config_snapshot function already calls validate_config_data when config.config_data is present. This call within validate_sdk_config is redundant and causes the validation to be performed twice. It's best to keep validation concerns separate and let the orchestrator (load_config_snapshot) handle the sequence of validation calls.



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")
57 changes: 54 additions & 3 deletions src/convert_sdk/core.py
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})"
1 change: 1 addition & 0 deletions src/convert_sdk/domain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Internal domain models for the Convert Python SDK."""
Loading