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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,20 @@ with Core(SDKConfig(data=config_data)).initialize() as core:

## Creating a visitor context

`create_context` binds a visitor identity (and optional attributes) to the
current immutable config snapshot:
`create_context` binds a visitor identity (and optional visitor attributes) to
the current immutable config snapshot:

```python
context = core.create_context(
"visitor-001",
attributes={"country": "US", "plan": "pro"},
visitor_attributes={"country": "US", "plan": "pro"},
)
```

Visitor attributes are used for audience qualification. They are copied
defensively — later mutations to the dict you pass never affect the context.
Keep and reuse the returned `context` to evaluate the same visitor repeatedly;
the SDK does not cache contexts for you.

## Experience evaluation

Expand Down
57 changes: 39 additions & 18 deletions src/convert_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, List, Mapping, Optional

from convert_sdk.domain.context_state import ContextState
from convert_sdk.domain.results import ExperienceResult, FeatureResult
from convert_sdk.evaluation.experiences import select_experience
from convert_sdk.evaluation.features import resolve_feature, resolve_features
Expand All @@ -37,12 +38,18 @@
class Context:
"""Per-visitor evaluation context for the Convert Python SDK.

Per-visitor state (identity + stored visitor attributes + the link to the
current immutable snapshot) lives in a typed
:class:`~convert_sdk.domain.context_state.ContextState`, keeping visitor
state separate from the shared snapshot. Location attributes remain a
context-local overlay concern and are not part of that visitor-state model.

Args:
visitor_id: The stable visitor identity used for deterministic bucketing.
snapshot: The immutable config snapshot to evaluate against.
attributes: Optional stored visitor attributes (e.g. audience traits).
Copied defensively so later caller mutations never affect the
context.
visitor_attributes: Optional stored visitor attributes (e.g. audience
traits). Copied defensively so later caller mutations never affect
the context.
location_attributes: Optional stored location attributes (e.g. URL /
site-area context) used for location-rule qualification.
"""
Expand All @@ -52,26 +59,40 @@ def __init__(
visitor_id: str,
snapshot: "ConfigSnapshot",
*,
attributes: Optional[Mapping[str, Any]] = None,
visitor_attributes: Optional[Mapping[str, Any]] = None,
location_attributes: Optional[Mapping[str, Any]] = None,
) -> None:
self._visitor_id = visitor_id
# Visitor identity + stored attributes + snapshot linkage live in the
# typed ContextState (visitor state stays separate from the snapshot).
self._state = ContextState(
visitor_id=visitor_id,
snapshot=snapshot,
visitor_attributes=visitor_attributes,
)
Comment on lines +67 to +71

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

In Context.__init__, visitor_attributes is typed as Optional[Mapping[str, Any]] = None. Passing it directly to ContextState when it is None violates the type annotation of ContextState.visitor_attributes, which is typed as Mapping[str, Any] (not optional). To ensure type safety and avoid static analysis warnings, default to an empty dictionary visitor_attributes or {} when instantiating ContextState.

Suggested change
self._state = ContextState(
visitor_id=visitor_id,
snapshot=snapshot,
visitor_attributes=visitor_attributes,
)
self._state = ContextState(
visitor_id=visitor_id,
snapshot=snapshot,
visitor_attributes=visitor_attributes or {},
)

self._snapshot = snapshot
# Store immutable copies so caller-side mutation cannot leak in.
self._attributes: Mapping[str, Any] = MappingProxyType(dict(attributes or {}))
# Location is a context-local overlay, not part of ContextState.
self._location_attributes: Mapping[str, Any] = MappingProxyType(
dict(location_attributes or {})
)

@property
def visitor_id(self) -> str:
"""The visitor identity bound to this context."""
return self._visitor_id
return self._state.visitor_id

@property
def visitor_attributes(self) -> Mapping[str, Any]:
"""A read-only view of the stored visitor attributes (Pythonic name)."""
return self._state.visitor_attributes

@property
def attributes(self) -> Mapping[str, Any]:
"""A read-only view of the stored visitor attributes."""
return self._attributes
"""A read-only view of the stored visitor attributes.

Retained alias for :attr:`visitor_attributes`; both expose the same
read-only stored visitor state.
"""
return self._state.visitor_attributes

# --- evaluation surface ------------------------------------------------

Expand Down Expand Up @@ -106,12 +127,12 @@ def run_experience(
(missing experience, unqualified visitor, no active variation). Never
raises for normal evaluation outcomes and performs no network I/O.
"""
visitor_attributes = self._merge(self._attributes, attributes)
visitor_attributes = self._state.with_overlay(attributes)
location = self._merge(self._location_attributes, location_attributes)
return select_experience(
experience_key,
self._snapshot,
visitor_id=self._visitor_id,
visitor_id=self._state.visitor_id,
visitor_attributes=visitor_attributes,
location_attributes=location,
)
Expand All @@ -129,7 +150,7 @@ def run_experiences(
omitted (no ``None`` entries). Evaluation stays local to the snapshot —
no network I/O.
"""
visitor_attributes = self._merge(self._attributes, attributes)
visitor_attributes = self._state.with_overlay(attributes)
location = self._merge(self._location_attributes, location_attributes)
results: List[ExperienceResult] = []
for experience in self._snapshot.experiences:
Expand All @@ -139,7 +160,7 @@ def run_experiences(
result = select_experience(
str(key),
self._snapshot,
visitor_id=self._visitor_id,
visitor_id=self._state.visitor_id,
visitor_attributes=visitor_attributes,
location_attributes=location,
)
Expand Down Expand Up @@ -169,12 +190,12 @@ def run_feature(
unqualified visitor). Never raises for normal evaluation outcomes and
performs no network I/O.
"""
visitor_attributes = self._merge(self._attributes, attributes)
visitor_attributes = self._state.with_overlay(attributes)
location = self._merge(self._location_attributes, location_attributes)
return resolve_feature(
feature_key,
self._snapshot,
visitor_id=self._visitor_id,
visitor_id=self._state.visitor_id,
visitor_attributes=visitor_attributes,
location_attributes=location,
)
Expand All @@ -192,11 +213,11 @@ def run_features(
``None`` entries). Evaluation stays local to the snapshot — no network
I/O.
"""
visitor_attributes = self._merge(self._attributes, attributes)
visitor_attributes = self._state.with_overlay(attributes)
location = self._merge(self._location_attributes, location_attributes)
return resolve_features(
self._snapshot,
visitor_id=self._visitor_id,
visitor_id=self._state.visitor_id,
visitor_attributes=visitor_attributes,
location_attributes=location,
)
21 changes: 16 additions & 5 deletions src/convert_sdk/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,26 @@ def current_config(self) -> Optional[ConfigSnapshot]:
def create_context(
self,
visitor_id: str,
attributes: Optional[Mapping[str, Any]] = None,
visitor_attributes: Optional[Mapping[str, Any]] = None,
*,
location_attributes: Optional[Mapping[str, Any]] = None,
) -> Context:
"""Create a visitor-scoped :class:`~convert_sdk.context.Context`.

The context evaluates against the current immutable snapshot. Attributes
are copied into the context defensively; later caller mutations do not
affect it. Requires the SDK to be initialized.
The context evaluates against the current immutable snapshot.
``visitor_attributes`` are copied into the context defensively; later
caller mutations do not affect it. The created context is a
caller-scoped per-visitor object — reuse means the caller keeps and
reuses the returned :class:`Context`; ``Core`` does not cache contexts.
Requires the SDK to be initialized.

Args:
visitor_id: The stable visitor identity used for deterministic
bucketing.
visitor_attributes: Optional stored visitor attributes (e.g.
audience traits) used for audience qualification.
location_attributes: Optional stored location attributes used for
location-rule qualification.

Raises:
RuntimeError: if called before :meth:`initialize` (no snapshot).
Expand All @@ -81,7 +92,7 @@ def create_context(
return Context(
visitor_id,
self._snapshot,
attributes=attributes,
visitor_attributes=visitor_attributes,
location_attributes=location_attributes,
)

Expand Down
71 changes: 71 additions & 0 deletions src/convert_sdk/domain/context_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Typed per-visitor context state (Story 1.3).

:class:`ContextState` is the typed, immutable foundation for a visitor context.
It keeps visitor-specific state — the visitor identity and stored visitor
attributes — strictly separate from the shared, immutable
:class:`~convert_sdk.domain.config_snapshot.ConfigSnapshot`:

* The snapshot is held *by reference*, shared across every context created from
the same initialized ``Core`` — it is never copied or mutated per visitor.
* Visitor attributes are copied defensively at construction and exposed only as
a read-only mapping, so caller-side mutation can never leak into the stored
state.
* Request-time attribute overlays are produced via :meth:`with_overlay`, which
returns a fresh mapping and never mutates the stored baseline (FR12/FR13).

This module is internal domain plumbing; nothing here is part of the public
``convert_sdk`` import boundary (the public surface stays ``Core`` / ``Context``
plus the typed result models). It exists so later persistence/segment stories
(3.2, 3.3) have a stable, snapshot-independent place to grow visitor state.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Mapping, Optional

if TYPE_CHECKING: # pragma: no cover - typing only
from convert_sdk.domain.config_snapshot import ConfigSnapshot


@dataclass(frozen=True)
class ContextState:
"""Immutable per-visitor state bound to the current config snapshot.

Args:
visitor_id: The stable visitor identity used for deterministic bucketing.
snapshot: The current immutable :class:`ConfigSnapshot`, shared by
reference (never copied or mutated per visitor).
visitor_attributes: Optional stored visitor attributes (e.g. audience
traits). Copied defensively and wrapped read-only so later caller
mutations never affect this state.
"""

visitor_id: str
snapshot: "ConfigSnapshot"
visitor_attributes: Mapping[str, Any] = field(default_factory=dict)

def __post_init__(self) -> None:
# Store a defensive, read-only copy so caller-side mutation cannot leak
# into the stored visitor state (frozen dataclass requires __setattr__).
if not isinstance(self.visitor_attributes, MappingProxyType):
object.__setattr__(
self,
"visitor_attributes",
MappingProxyType(dict(self.visitor_attributes or {})),
)
Comment on lines +52 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.

high

The current implementation of defensive copying checks isinstance(self.visitor_attributes, MappingProxyType) to skip copying if the attributes are already wrapped in a MappingProxyType. However, a MappingProxyType is only a read-only view of an underlying dictionary. If the caller passes a MappingProxyType that wraps a mutable dictionary and subsequently mutates that dictionary, those mutations will leak into the context state, violating the defensive copying guarantee. Always copying the mapping ensures complete isolation.

        object.__setattr__(
            self,
            "visitor_attributes",
            MappingProxyType(dict(self.visitor_attributes or {})),
        )


def with_overlay(self, overlay: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
"""Return stored visitor attributes overlaid with request-time values.

Per-call ``overlay`` keys override stored keys for that call only.
Returns a fresh mapping; the stored baseline (and ``overlay``) are never
mutated. When ``overlay`` is empty/``None`` the read-only baseline view
is returned unchanged.
"""
if not overlay:
return self.visitor_attributes
merged = dict(self.visitor_attributes)
merged.update(overlay)
return merged
Loading