Skip to content

Commit 65b755d

Browse files
authored
Merge branch 'main' into claude/issue-87-20251027-0227
2 parents 302b39f + a694fe8 commit 65b755d

File tree

16 files changed

+245
-24
lines changed

16 files changed

+245
-24
lines changed

.github/workflows/claude-on-mention.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ jobs:
2727
with:
2828
fetch-depth: 1
2929

30+
- name: Set up Python 3.10
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.10'
34+
3035
# Install UV package manager
3136
- name: Install UV
3237
uses: astral-sh/setup-uv@v7

.github/workflows/claude-on-open-label.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ jobs:
2222
with:
2323
fetch-depth: 1
2424

25+
- name: Set up Python 3.10
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: '3.10'
29+
2530
# Install UV package manager
2631
- name: Install UV
2732
uses: astral-sh/setup-uv@v7

key-value/key-value-sync/src/key_value/sync/code_gen/adapters/pydantic/adapter.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ def __init__(
5050
self._raise_on_validation_error = raise_on_validation_error
5151

5252
def _validate_model(self, value: dict[str, Any]) -> T | None:
53+
"""Validate and deserialize a dict into the configured Pydantic model.
54+
55+
This method handles both single models and list models. For list models, it expects the value
56+
to contain an "items" key with the list data, following the convention used by `_serialize_model`.
57+
If validation fails and `raise_on_validation_error` is False, returns None instead of raising.
58+
59+
Args:
60+
value: The dict to validate and convert to a Pydantic model.
61+
62+
Returns:
63+
The validated model instance, or None if validation fails and errors are suppressed.
64+
65+
Raises:
66+
DeserializationError: If validation fails and `raise_on_validation_error` is True.
67+
"""
5368
try:
5469
if self._is_list_model:
5570
return self._type_adapter.validate_python(value.get("items", []))
@@ -62,6 +77,22 @@ def _validate_model(self, value: dict[str, Any]) -> T | None:
6277
return None
6378

6479
def _serialize_model(self, value: T) -> dict[str, Any]:
80+
"""Serialize a Pydantic model to a dict for storage.
81+
82+
This method handles both single models and list models. For list models, it wraps the serialized
83+
list in a dict with an "items" key (e.g., {"items": [...]}) to ensure consistent dict-based storage
84+
format across all value types. This wrapping convention is expected by `_validate_model` during
85+
deserialization.
86+
87+
Args:
88+
value: The Pydantic model instance to serialize.
89+
90+
Returns:
91+
A dict representation of the model suitable for storage.
92+
93+
Raises:
94+
SerializationError: If the model cannot be serialized.
95+
"""
6596
try:
6697
if self._is_list_model:
6798
return {"items": self._type_adapter.dump_python(value, mode="json")}
@@ -91,6 +122,10 @@ def get(self, key: str, *, collection: str | None = None, default: T | None = No
91122
Raises:
92123
DeserializationError if the stored data cannot be validated as the model and the PydanticAdapter is configured to
93124
raise on validation error.
125+
126+
Note:
127+
When raise_on_validation_error=False and validation fails, returns the default value (which may be None).
128+
When raise_on_validation_error=True and validation fails, raises DeserializationError.
94129
"""
95130
collection = collection or self._default_collection
96131

@@ -121,6 +156,11 @@ def get_many(self, keys: Sequence[str], *, collection: str | None = None, defaul
121156
Raises:
122157
DeserializationError if the stored data cannot be validated as the model and the PydanticAdapter is configured to
123158
raise on validation error.
159+
160+
Note:
161+
When raise_on_validation_error=False and validation fails for any key, that position in the returned list
162+
will contain the default value (which may be None). The method returns a complete list matching the order
163+
and length of the input keys, with defaults substituted for missing or invalid entries.
124164
"""
125165
collection = collection or self._default_collection
126166

@@ -171,7 +211,16 @@ def delete_many(self, keys: Sequence[str], *, collection: str | None = None) ->
171211
def ttl(self, key: str, *, collection: str | None = None) -> tuple[T | None, float | None]:
172212
"""Get a model and its TTL seconds if present.
173213
174-
Returns (model, ttl_seconds) or (None, None) if missing.
214+
Args:
215+
key: The key to retrieve.
216+
collection: The collection to use. If not provided, uses the default collection.
217+
218+
Returns:
219+
A tuple of (model, ttl_seconds). Returns (None, None) if the key is missing or validation fails.
220+
221+
Note:
222+
When validation fails and raise_on_validation_error=False, returns (None, None) even if TTL data exists.
223+
When validation fails and raise_on_validation_error=True, raises DeserializationError.
175224
"""
176225
collection = collection or self._default_collection
177226

key-value/key-value-sync/src/key_value/sync/code_gen/protocols/key_value.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77

88
@runtime_checkable
99
class KeyValueProtocol(Protocol):
10-
"""A subset of KV operations: get/put/delete and TTL variants, including bulk calls."""
10+
"""A subset of KV operations: get/put/delete and TTL variants, including bulk calls.
11+
12+
This protocol defines the minimal contract for key-value store implementations. All methods are
13+
async and may raise exceptions on connection failures, validation errors, or other operational issues.
14+
Implementations should handle backend-specific errors appropriately.
15+
"""
1116

1217
def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None:
1318
"""Retrieve a value by key from the specified collection.
@@ -52,6 +57,9 @@ def delete(self, key: str, *, collection: str | None = None) -> bool:
5257
Args:
5358
key: The key to delete the value from.
5459
collection: The collection to delete the value from. If no collection is provided, it will use the default collection.
60+
61+
Returns:
62+
True if the key was deleted, False if the key did not exist.
5563
"""
5664
...
5765

key-value/key-value-sync/src/key_value/sync/code_gen/stores/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535

3636

3737
def _seed_to_frozen_seed_data(seed: SEED_DATA_TYPE) -> FROZEN_SEED_DATA_TYPE:
38+
"""Convert mutable seed data to an immutable frozen structure.
39+
40+
This function converts the nested mapping structure of seed data into immutable MappingProxyType
41+
objects at all levels. Using immutable structures prevents accidental modification of seed data
42+
after store initialization and ensures thread-safety.
43+
44+
Args:
45+
seed: The mutable seed data mapping: {collection: {key: {field: value}}}.
46+
47+
Returns:
48+
An immutable frozen version of the seed data using MappingProxyType.
49+
"""
3850
return MappingProxyType(
3951
{
4052
collection: MappingProxyType({key: MappingProxyType(value) for (key, value) in items.items()})
@@ -107,6 +119,15 @@ def _seed_store(self) -> None:
107119
self.put(key=key, value=dict(value), collection=collection)
108120

109121
def setup(self) -> None:
122+
"""Initialize the store if not already initialized.
123+
124+
This method is called automatically before any store operations and uses a lock to ensure
125+
thread-safe lazy initialization. It can also be called manually to ensure the store is ready
126+
before performing operations. The setup process includes calling the `_setup()` hook and
127+
seeding the store with initial data if provided.
128+
129+
This method is idempotent - calling it multiple times has no additional effect after the first call.
130+
"""
110131
if not self._setup_complete:
111132
with self._setup_lock:
112133
if not self._setup_complete:
@@ -122,6 +143,19 @@ def setup(self) -> None:
122143
self._seed_store()
123144

124145
def setup_collection(self, *, collection: str) -> None:
146+
"""Initialize a specific collection if not already initialized.
147+
148+
This method is called automatically before any collection-specific operations and uses a per-collection
149+
lock to ensure thread-safe lazy initialization. It can also be called manually to ensure a collection
150+
is ready before performing operations on it. The setup process includes calling the `_setup_collection()`
151+
hook for store-specific collection initialization.
152+
153+
This method is idempotent - calling it multiple times for the same collection has no additional effect
154+
after the first call.
155+
156+
Args:
157+
collection: The name of the collection to initialize.
158+
"""
125159
self.setup()
126160

127161
if not self._setup_collection_complete[collection]:
@@ -218,6 +252,7 @@ def _put_managed_entries(
218252
created_at: datetime,
219253
expires_at: datetime | None,
220254
) -> None:
255+
221256
"""Store multiple managed entries by key in the specified collection.
222257
223258
Args:

key-value/key-value-sync/src/key_value/sync/code_gen/stores/memory/store.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,28 +148,42 @@ def _setup_collection(self, *, collection: str) -> None:
148148
collection_cache = MemoryCollection(max_entries=self.max_entries_per_collection)
149149
self._cache[collection] = collection_cache
150150

151+
def _get_collection_or_raise(self, collection: str) -> MemoryCollection:
152+
"""Get a collection or raise KeyError if not setup.
153+
154+
Args:
155+
collection: The collection name.
156+
157+
Returns:
158+
The MemoryCollection instance.
159+
160+
Raises:
161+
KeyError: If the collection has not been setup via setup_collection().
162+
"""
163+
collection_cache: MemoryCollection | None = self._cache.get(collection)
164+
if collection_cache is None:
165+
msg = f"Collection '{collection}' has not been setup. Call setup_collection() first."
166+
raise KeyError(msg)
167+
return collection_cache
168+
151169
@override
152170
def _get_managed_entry(self, *, key: str, collection: str) -> ManagedEntry | None:
153-
collection_cache: MemoryCollection = self._cache[collection]
154-
171+
collection_cache = self._get_collection_or_raise(collection)
155172
return collection_cache.get(key=key)
156173

157174
@override
158175
def _put_managed_entry(self, *, key: str, collection: str, managed_entry: ManagedEntry) -> None:
159-
collection_cache: MemoryCollection = self._cache[collection]
160-
176+
collection_cache = self._get_collection_or_raise(collection)
161177
collection_cache.put(key=key, value=managed_entry)
162178

163179
@override
164180
def _delete_managed_entry(self, *, key: str, collection: str) -> bool:
165-
collection_cache: MemoryCollection = self._cache[collection]
166-
181+
collection_cache = self._get_collection_or_raise(collection)
167182
return collection_cache.delete(key=key)
168183

169184
@override
170185
def _get_collection_keys(self, *, collection: str, limit: int | None = None) -> list[str]:
171-
collection_cache: MemoryCollection = self._cache[collection]
172-
186+
collection_cache = self._get_collection_or_raise(collection)
173187
return collection_cache.keys(limit=limit)
174188

175189
@override

key-value/key-value-sync/src/key_value/sync/code_gen/stores/mongodb/store.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,34 @@
4141

4242

4343
def document_to_managed_entry(document: dict[str, Any]) -> ManagedEntry:
44-
"""
45-
Convert a MongoDB document to a ManagedEntry.
44+
"""Convert a MongoDB document back to a ManagedEntry.
45+
46+
This function deserializes a MongoDB document (created by `managed_entry_to_document`) back to a
47+
ManagedEntry object, parsing the stringified value field and preserving all metadata.
48+
49+
Args:
50+
document: The MongoDB document to convert.
51+
52+
Returns:
53+
A ManagedEntry object reconstructed from the document.
4654
"""
4755
return ManagedEntry.from_dict(data=document, stringified_value=True)
4856

4957

5058
def managed_entry_to_document(key: str, managed_entry: ManagedEntry) -> dict[str, Any]:
51-
"""
52-
Convert a ManagedEntry to a MongoDB document.
59+
"""Convert a ManagedEntry to a MongoDB document for storage.
60+
61+
This function serializes a ManagedEntry to a MongoDB document format, including the key and all
62+
metadata (TTL, creation, and expiration timestamps). The value is stringified to ensure proper
63+
storage in MongoDB. The serialization is designed to preserve all entry information for round-trip
64+
conversion back to a ManagedEntry.
65+
66+
Args:
67+
key: The key associated with this entry.
68+
managed_entry: The ManagedEntry to serialize.
69+
70+
Returns:
71+
A MongoDB document dict containing the key, value, and all metadata.
5372
"""
5473
return {
5574
"key": key,
@@ -134,6 +153,18 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # pyright
134153
self._client.__exit__(exc_type, exc_val, exc_tb)
135154

136155
def _sanitize_collection_name(self, collection: str) -> str:
156+
"""Sanitize a collection name to meet MongoDB naming requirements.
157+
158+
MongoDB has specific requirements for collection names (length limits, allowed characters).
159+
This method ensures collection names are compliant by truncating to the maximum allowed length
160+
and replacing invalid characters with safe alternatives.
161+
162+
Args:
163+
collection: The collection name to sanitize.
164+
165+
Returns:
166+
A sanitized collection name that meets MongoDB requirements.
167+
"""
137168
return sanitize_string(value=collection, max_length=MAX_COLLECTION_LENGTH, allowed_characters=ALPHANUMERIC_CHARACTERS)
138169

139170
@override

key-value/key-value-sync/src/key_value/sync/code_gen/stores/redis/store.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,32 @@
2424

2525

2626
def managed_entry_to_json(managed_entry: ManagedEntry) -> str:
27-
"""
28-
Convert a ManagedEntry to a JSON string.
27+
"""Convert a ManagedEntry to a JSON string for Redis storage.
28+
29+
This function serializes a ManagedEntry to JSON format including all metadata (TTL, creation,
30+
and expiration timestamps). The serialization is designed to preserve all entry information
31+
for round-trip conversion back to a ManagedEntry.
32+
33+
Args:
34+
managed_entry: The ManagedEntry to serialize.
35+
36+
Returns:
37+
A JSON string representation of the ManagedEntry with full metadata.
2938
"""
3039
return managed_entry.to_json(include_metadata=True, include_expiration=True, include_creation=True)
3140

3241

3342
def json_to_managed_entry(json_str: str) -> ManagedEntry:
34-
"""
35-
Convert a JSON string to a ManagedEntry.
43+
"""Convert a JSON string from Redis storage back to a ManagedEntry.
44+
45+
This function deserializes a JSON string (created by `managed_entry_to_json`) back to a
46+
ManagedEntry object, preserving all metadata including TTL, creation, and expiration timestamps.
47+
48+
Args:
49+
json_str: The JSON string to deserialize.
50+
51+
Returns:
52+
A ManagedEntry object reconstructed from the JSON string.
3653
"""
3754
return ManagedEntry.from_json(json_str=json_str, includes_metadata=True)
3855

key-value/key-value-sync/src/key_value/sync/code_gen/wrappers/base.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,24 @@
1111

1212

1313
class BaseWrapper(KeyValue):
14-
"""A base wrapper for KVStore implementations that passes through to the underlying store."""
14+
"""A base wrapper for KVStore implementations that passes through to the underlying store.
15+
16+
This class implements the passthrough pattern where all operations are delegated to the wrapped
17+
key-value store without modification. It serves as a foundation for creating custom wrappers that
18+
need to intercept, modify, or enhance specific operations while passing through others unchanged.
19+
20+
To create a custom wrapper, subclass this class and override only the methods you need to customize.
21+
All other operations will automatically pass through to the underlying store.
22+
23+
Example:
24+
class LoggingWrapper(BaseWrapper):
25+
async def get(self, key: str, *, collection: str | None = None):
26+
logger.info(f"Getting key: {key}")
27+
return await super().get(key, collection=collection)
28+
29+
Attributes:
30+
key_value: The underlying KeyValue store that operations are delegated to.
31+
"""
1532

1633
key_value: KeyValue
1734

key-value/key-value-sync/src/key_value/sync/code_gen/wrappers/default_value/wrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ def __init__(self, key_value: KeyValue, default_value: Mapping[str, Any], defaul
3838
self._default_value_json = dump_to_json(obj=dict(default_value))
3939
self._default_ttl = None if default_ttl is None else float(default_ttl)
4040

41+
super().__init__()
42+
4143
def _new_default_value(self) -> dict[str, Any]:
4244
return load_from_json(json_str=self._default_value_json)
4345

0 commit comments

Comments
 (0)