digitalkin 0.2.7__py3-none-any.whl → 0.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
digitalkin/__version__.py CHANGED
@@ -5,4 +5,4 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  try:
6
6
  __version__ = version("digitalkin")
7
7
  except PackageNotFoundError:
8
- __version__ = "0.2.7"
8
+ __version__ = "0.2.8"
@@ -5,6 +5,7 @@ import json
5
5
  import logging
6
6
  import tempfile
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -14,115 +15,91 @@ logger = logging.getLogger(__name__)
14
15
 
15
16
 
16
17
  class DefaultStorage(StorageStrategy):
17
- """This class implements the default storage strategy with file persistence.
18
+ """Persist records in a local JSON file for quick local development.
18
19
 
19
- The storage persists data in a local JSON file, enabling data retention
20
- across multiple requests or module instances.
20
+ File format: a JSON object of
21
+ { "<collection>:<record_id>": { ... StorageRecord fields ... },
21
22
  """
22
23
 
24
+ @staticmethod
25
+ def _json_default(o: Any) -> str: # noqa: ANN401
26
+ """JSON serializer for non-standard types (datetime → ISO).
27
+
28
+ Args:
29
+ o: The object to serialize
30
+
31
+ Returns:
32
+ str: The serialized object
33
+
34
+ Raises:
35
+ TypeError: If the object is not serializable
36
+ """
37
+ if isinstance(o, datetime.datetime):
38
+ return o.isoformat()
39
+ msg = f"Type {o.__class__.__name__} not serializable"
40
+ raise TypeError(msg)
41
+
23
42
  def _load_from_file(self) -> dict[str, StorageRecord]:
24
43
  """Load storage data from the file.
25
44
 
26
45
  Returns:
27
46
  A dictionary containing the loaded storage records
28
47
  """
29
- file_path = Path(self.storage_file_path)
30
- if not file_path.exists():
48
+ if not self.storage_file.exists():
31
49
  return {}
32
50
 
33
51
  try:
34
- records = {}
35
- file_content = json.loads(file_path.read_text(encoding="utf-8"))
36
-
37
- for key, record_dict in file_content.items():
38
- # Get the stored record data
39
- name = record_dict.get("name", "")
40
- model_class = self.config.get(name)
41
-
42
- if not model_class:
43
- logger.warning("No model found for record %s", name)
52
+ raw = json.loads(self.storage_file.read_text(encoding="utf-8"))
53
+ out: dict[str, StorageRecord] = {}
54
+
55
+ for key, rd in raw.items():
56
+ # rd is a dict with the StorageRecord fields
57
+ model_cls = self.config.get(rd["collection"])
58
+ if not model_cls:
59
+ logger.warning("No model for collection '%s'", rd["collection"])
44
60
  continue
45
-
46
- # Create a model instance from the stored data
47
- data_dict = record_dict.get("data", {})
48
- try:
49
- data_model = model_class.model_validate(data_dict)
50
- except Exception:
51
- logger.exception("Failed to validate data for record %s", name)
52
- continue
53
-
54
- # Create a StorageRecord object
55
- record = StorageRecord(
56
- mission_id=record_dict.get("mission_id", ""),
57
- name=name,
61
+ data_model = model_cls.model_validate(rd["data"])
62
+ rec = StorageRecord(
63
+ mission_id=rd["mission_id"],
64
+ collection=rd["collection"],
65
+ record_id=rd["record_id"],
58
66
  data=data_model,
59
- data_type=DataType[record_dict.get("data_type", "OUTPUT")],
67
+ data_type=DataType[rd["data_type"]],
68
+ creation_date=datetime.datetime.fromisoformat(rd["creation_date"])
69
+ if rd.get("creation_date")
70
+ else None,
71
+ update_date=datetime.datetime.fromisoformat(rd["update_date"]) if rd.get("update_date") else None,
60
72
  )
61
-
62
- # Set dates if they exist
63
- if "creation_date" in record_dict:
64
- record.creation_date = datetime.datetime.fromisoformat(record_dict["creation_date"])
65
- if "update_date" in record_dict:
66
- record.update_date = datetime.datetime.fromisoformat(record_dict["update_date"])
67
-
68
- records[key] = record
69
- except json.JSONDecodeError:
70
- logger.exception("Error decoding JSON from file")
71
- return {}
72
- except FileNotFoundError:
73
- logger.info("Storage file not found, starting with empty storage")
74
- return {}
73
+ out[key] = rec
75
74
  except Exception:
76
- logger.exception("Unexpected error loading storage")
75
+ logger.exception("Failed to load default storage file")
77
76
  return {}
78
- return records
77
+ return out
79
78
 
80
79
  def _save_to_file(self) -> None:
81
- """Save storage data to the file using a safe write pattern."""
82
- # Usage of pathlib for file operations
83
- file_path = Path(self.storage_file_path)
84
- file_path.parent.mkdir(parents=True, exist_ok=True)
85
-
86
- try:
87
- # Convert storage to a serializable format
88
- serializable_data = {}
89
- for key, record in self.storage.items():
90
- record_dict = {
91
- "mission_id": record.mission_id,
92
- "name": record.name,
93
- "data_type": record.data_type.name, # Convert enum to string
94
- "data": record.data.model_dump(), # Convert Pydantic model to dict
95
- }
96
-
97
- # Handle dates (convert to ISO format strings)
98
- if record.creation_date:
99
- record_dict["creation_date"] = record.creation_date.isoformat()
100
- if record.update_date:
101
- record_dict["update_date"] = record.update_date.isoformat()
102
-
103
- serializable_data[key] = record_dict
104
-
105
- # usage of NamedTemporaryFile for atomic writes
106
- with tempfile.NamedTemporaryFile(
107
- mode="w", encoding="utf-8", dir=str(file_path.parent), delete=False, suffix=".tmp"
108
- ) as temp_file:
109
- json.dump(serializable_data, temp_file, indent=2)
110
- temp_path = temp_file.name
111
-
112
- # Creation of a backup if the file already exists
113
- if file_path.exists():
114
- backup_path = f"{self.storage_file_path}.bak"
115
- file_path.replace(backup_path)
116
-
117
- # Remplacement du fichier (opération atomique) avec pathlib
118
- Path(temp_path).replace(str(file_path))
119
-
120
- except PermissionError:
121
- logger.exception("Permission denied when saving to file")
122
- except OSError:
123
- logger.exception("OS error when saving to file")
124
- except Exception:
125
- logger.exception("Unexpected error saving storage")
80
+ """Atomically write `self.storage` back to disk as JSON."""
81
+ self.storage_file.parent.mkdir(parents=True, exist_ok=True)
82
+ with tempfile.NamedTemporaryFile(
83
+ mode="w", encoding="utf-8", delete=False, dir=str(self.storage_file.parent), suffix=".tmp"
84
+ ) as temp:
85
+ try:
86
+ # Convert storage to a serializable format
87
+ serial: dict[str, dict] = {}
88
+ for key, record in self.storage.items():
89
+ serial[key] = {
90
+ "mission_id": record.mission_id,
91
+ "collection": record.collection,
92
+ "record_id": record.record_id,
93
+ "data_type": record.data_type.name,
94
+ "data": record.data.model_dump(),
95
+ "creation_date": record.creation_date.isoformat() if record.creation_date else None,
96
+ "update_date": record.update_date.isoformat() if record.update_date else None,
97
+ }
98
+ json.dump(serial, temp, indent=2, default=self._json_default)
99
+ temp.flush()
100
+ Path(temp.name).replace(self.storage_file)
101
+ except Exception:
102
+ logger.exception("Unexpected error saving storage")
126
103
 
127
104
  def _store(self, record: StorageRecord) -> StorageRecord:
128
105
  """Store a new record in the database and persist to file.
@@ -136,73 +113,97 @@ class DefaultStorage(StorageStrategy):
136
113
  Raises:
137
114
  ValueError: If the record already exists
138
115
  """
139
- name = record.name
140
- if name in self.storage:
141
- msg = f"Record with name {name} already exists"
116
+ key = f"{record.collection}:{record.record_id}"
117
+ if key in self.storage:
118
+ msg = f"Document {key!r} already exists"
142
119
  raise ValueError(msg)
143
- self.storage[name] = record
144
- self.storage[name].creation_date = datetime.datetime.now(datetime.timezone.utc)
145
- self.storage[name].update_date = datetime.datetime.now(datetime.timezone.utc)
146
-
147
- # Persist to file
120
+ now = datetime.datetime.now(datetime.timezone.utc)
121
+ record.creation_date = now
122
+ record.update_date = now
123
+ self.storage[key] = record
148
124
  self._save_to_file()
125
+ logger.debug("Created %s", key)
126
+ return record
149
127
 
150
- logger.info("CREATE %s:%s successful", name, record)
151
- return self.storage[name]
152
-
153
- def _read(self, name: str) -> StorageRecord | None:
128
+ def _read(self, collection: str, record_id: str) -> StorageRecord | None:
154
129
  """Get records from the database.
155
130
 
156
131
  Args:
157
- name: The unique name to retrieve data for
132
+ collection: The unique name to retrieve data for
133
+ record_id: The unique ID of the record
158
134
 
159
135
  Returns:
160
136
  StorageRecord: The corresponding record
161
137
  """
162
- logger.info("GET record linked to the key = %s", name)
163
- if name not in self.storage:
164
- logger.info("GET key = %s: DOESN'T EXIST", name)
165
- return None
166
- return self.storage[name]
138
+ key = f"{collection}:{record_id}"
139
+ return self.storage.get(key)
167
140
 
168
- def _modify(self, name: str, data: BaseModel) -> StorageRecord | None:
141
+ def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None:
169
142
  """Update records in the database and persist to file.
170
143
 
171
144
  Args:
172
- name: The unique name to store the data under
145
+ collection: The unique name to retrieve data for
146
+ record_id: The unique ID of the record
173
147
  data: The data to modify
174
148
 
175
149
  Returns:
176
150
  StorageRecord: The modified record
177
151
  """
178
- if name not in self.storage:
179
- logger.info("UPDATE key = %s: DOESN'T EXIST", name)
152
+ key = f"{collection}:{record_id}"
153
+ rec = self.storage.get(key)
154
+ if not rec:
180
155
  return None
181
- self.storage[name].data = data
182
- self.storage[name].update_date = datetime.datetime.now(datetime.timezone.utc)
183
-
184
- # Persist to file
156
+ rec.data = data
157
+ rec.update_date = datetime.datetime.now(datetime.timezone.utc)
185
158
  self._save_to_file()
159
+ logger.debug("Modified %s", key)
160
+ return rec
186
161
 
187
- return self.storage[name]
188
-
189
- def _remove(self, name: str) -> bool:
162
+ def _remove(self, collection: str, record_id: str) -> bool:
190
163
  """Delete records from the database and update file.
191
164
 
192
165
  Args:
193
- name: The unique name to remove a record
166
+ collection: The unique name to retrieve data for
167
+ record_id: The unique ID of the record
194
168
 
195
169
  Returns:
196
170
  bool: True if the record was removed, False otherwise
197
171
  """
198
- if name not in self.storage:
199
- logger.info("DELETE key = %s: DOESN'T EXIST", name)
172
+ key = f"{collection}:{record_id}"
173
+ if key not in self.storage:
200
174
  return False
201
- del self.storage[name]
202
-
203
- # Persist to file
175
+ del self.storage[key]
204
176
  self._save_to_file()
177
+ logger.debug("Removed %s", key)
178
+ return True
179
+
180
+ def _list(self, collection: str) -> list[StorageRecord]:
181
+ """Implements StorageStrategy._list.
182
+
183
+ Args:
184
+ collection: The unique name to retrieve data for
205
185
 
186
+ Returns:
187
+ A list of storage records
188
+ """
189
+ prefix = f"{collection}:"
190
+ return [r for k, r in self.storage.items() if k.startswith(prefix)]
191
+
192
+ def _remove_collection(self, collection: str) -> bool:
193
+ """Implements StorageStrategy._remove_collection.
194
+
195
+ Args:
196
+ collection: The unique name to retrieve data for
197
+
198
+ Returns:
199
+ bool: True if the collection was removed, False otherwise
200
+ """
201
+ prefix = f"{collection}:"
202
+ to_delete = [k for k in self.storage if k.startswith(prefix)]
203
+ for k in to_delete:
204
+ del self.storage[k]
205
+ self._save_to_file()
206
+ logger.debug("Removed collection %s (%d docs)", collection, len(to_delete))
206
207
  return True
207
208
 
208
209
  def __init__(
@@ -215,4 +216,5 @@ class DefaultStorage(StorageStrategy):
215
216
  """Initialize the storage."""
216
217
  super().__init__(mission_id=mission_id, config=config)
217
218
  self.storage_file_path = f"{self.mission_id}_{storage_file_path}.json"
219
+ self.storage_file = Path(self.storage_file_path)
218
220
  self.storage = self._load_from_file()
@@ -17,19 +17,36 @@ logger = logging.getLogger(__name__)
17
17
  class GrpcStorage(StorageStrategy, GrpcClientWrapper):
18
18
  """This class implements the default storage strategy."""
19
19
 
20
- def __init__(
21
- self,
22
- mission_id: str,
23
- config: dict[str, type[BaseModel]],
24
- client_config: ClientConfig,
25
- **kwargs, # noqa: ANN003, ARG002
26
- ) -> None:
27
- """Initialize the storage."""
28
- super().__init__(mission_id=mission_id, config=config)
20
+ def _build_record_from_proto(self, proto: data_pb2.StorageRecord) -> StorageRecord:
21
+ """Convert a protobuf StorageRecord message into our Pydantic model.
29
22
 
30
- channel = self._init_channel(client_config)
31
- self.stub = storage_service_pb2_grpc.StorageServiceStub(channel)
32
- logger.info("Channel client 'storage' initialized succesfully")
23
+ Args:
24
+ proto: gRPC StorageRecord
25
+
26
+ Returns:
27
+ A fully validated StorageRecord.
28
+ """
29
+ raw = json_format.MessageToDict(
30
+ proto,
31
+ preserving_proto_field_name=True,
32
+ always_print_fields_with_no_presence=True,
33
+ )
34
+ mission = raw["mission_id"]
35
+ coll = raw["collection"]
36
+ rid = raw["record_id"]
37
+ dtype = DataType[raw["data_type"]]
38
+ payload = raw.get("data", {})
39
+
40
+ validated = self._validate_data(coll, payload)
41
+ return StorageRecord(
42
+ mission_id=mission,
43
+ collection=coll,
44
+ record_id=rid,
45
+ data=validated,
46
+ data_type=dtype,
47
+ creation_date=raw.get("creation_date"),
48
+ update_date=raw.get("update_date"),
49
+ )
33
50
 
34
51
  def _store(self, record: StorageRecord) -> StorageRecord:
35
52
  """Create a new record in the database.
@@ -44,113 +61,137 @@ class GrpcStorage(StorageStrategy, GrpcClientWrapper):
44
61
  StorageServiceError: If there is an error while storing the record
45
62
  """
46
63
  try:
47
- # Create a Struct for the data
48
64
  data_struct = Struct()
49
65
  data_struct.update(record.data.model_dump())
50
-
51
- request = data_pb2.StoreRecordRequest(
66
+ req = data_pb2.StoreRecordRequest(
52
67
  data=data_struct,
53
68
  mission_id=record.mission_id,
54
- name=record.name,
69
+ collection=record.collection,
70
+ record_id=record.record_id,
55
71
  data_type=record.data_type.name,
56
72
  )
57
- return self.exec_grpc_query("StoreRecord", request)
58
- except Exception:
59
- msg = f"Error while storing record {record.name}"
60
- logger.exception(msg)
61
- raise StorageServiceError(msg)
73
+ resp = self.exec_grpc_query("StoreRecord", req)
74
+ return self._build_record_from_proto(resp.stored_data)
75
+ except Exception as e:
76
+ logger.exception("gRPC StoreRecord failed for %s:%s", record.collection, record.record_id)
77
+ raise StorageServiceError(str(e)) from e
62
78
 
63
- def _read(self, name: str) -> StorageRecord | None:
64
- """Get records from the database.
79
+ def _read(self, collection: str, record_id: str) -> StorageRecord | None:
80
+ """Fetch a single document by collection + record_id.
65
81
 
66
82
  Returns:
67
- list[StorageData]: The list of records
83
+ StorageData: The record
68
84
  """
69
85
  try:
70
- request = data_pb2.ReadRecordRequest(mission_id=self.mission_id, name=name)
71
- response: data_pb2.ReadRecordResponse = self.exec_grpc_query("ReadRecord", request)
72
- response_dict = json_format.MessageToDict(
73
- response.stored_data,
74
- preserving_proto_field_name=True,
75
- always_print_fields_with_no_presence=True,
76
- )
77
- return StorageRecord(
78
- mission_id=response_dict["mission_id"],
79
- name=response_dict["name"],
80
- data_type=response_dict["data_type"],
81
- data=self._validate_data(name, response_dict["data"]),
86
+ req = data_pb2.ReadRecordRequest(
87
+ mission_id=self.mission_id,
88
+ collection=collection,
89
+ record_id=record_id,
82
90
  )
91
+ resp = self.exec_grpc_query("ReadRecord", req)
92
+ return self._build_record_from_proto(resp.stored_data)
83
93
  except Exception:
84
- msg = f"Error while reading record {name}"
85
- logger.exception(msg)
94
+ logger.exception("gRPC ReadRecord failed for %s:%s", collection, record_id)
86
95
  return None
87
96
 
88
- def _modify(self, name: str, data: BaseModel) -> StorageRecord | None:
89
- """Update records in the database.
97
+ def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None:
98
+ """Overwrite a document via gRPC.
99
+
100
+ Args:
101
+ collection: The unique name for the record type
102
+ record_id: The unique ID for the record
103
+ data: The validated data model
90
104
 
91
105
  Returns:
92
- int: The number of records updated
106
+ StorageRecord: The updated record
93
107
  """
94
108
  try:
95
- # Create a Struct for the data
96
- data_struct = Struct()
97
- data_struct.update(data.model_dump())
98
-
99
- request = data_pb2.ModifyRecordRequest(data=data_struct, mission_id=self.mission_id, name=name)
100
- response: data_pb2.ModifyRecordResponse = self.exec_grpc_query("ModifyRecord", request)
101
- return self._build_record_from_proto(response.stored_data, name)
109
+ struct = Struct()
110
+ struct.update(data.model_dump())
111
+ req = data_pb2.UpdateRecordRequest(
112
+ data=struct,
113
+ mission_id=self.mission_id,
114
+ collection=collection,
115
+ record_id=record_id,
116
+ )
117
+ resp = self.exec_grpc_query("ModifyRecord", req)
118
+ return self._build_record_from_proto(resp.stored_data)
102
119
  except Exception:
103
- msg = f"Error while modifing record {name}"
104
- logger.exception(msg)
120
+ logger.exception("gRPC ModifyRecord failed for %s:%s", collection, record_id)
105
121
  return None
106
122
 
107
- def _remove(self, name: str) -> bool:
108
- """Delete records from the database.
123
+ def _remove(self, collection: str, record_id: str) -> bool:
124
+ """Delete a document via gRPC.
125
+
126
+ Args:
127
+ collection: The unique name for the record type
128
+ record_id: The unique ID for the record
109
129
 
110
130
  Returns:
111
- int: The number of records deleted
131
+ bool: True if the record was deleted, False otherwise
112
132
  """
113
133
  try:
114
- request = data_pb2.RemoveRecordRequest(
134
+ req = data_pb2.RemoveRecordRequest(
115
135
  mission_id=self.mission_id,
116
- name=name,
136
+ collection=collection,
137
+ record_id=record_id,
117
138
  )
118
- self.exec_grpc_query("RemoveRecord", request)
139
+ self.exec_grpc_query("RemoveRecord", req)
119
140
  except Exception:
120
- msg = f"Error while removin record {name}"
121
- logger.exception(msg)
141
+ logger.exception("gRPC RemoveRecord failed for %s:%s", collection, record_id)
122
142
  return False
123
143
  return True
124
144
 
125
- def _build_record_from_proto(self, stored_data: data_pb2.StorageRecord, default_name: str) -> StorageRecord:
126
- """Helper to construire un StorageRecord complet à partir du message gRPC.
145
+ def _list(self, collection: str) -> list[StorageRecord]:
146
+ """List all documents in a collection via gRPC.
127
147
 
128
148
  Args:
129
- stored_data: Le message gRPC contenant les données stockées.
130
- default_name: Le nom par défaut à utiliser si le nom n'est pas présent dans les données.
149
+ collection: The unique name for the record type
131
150
 
132
151
  Returns:
133
- StorageRecord: Un objet StorageRecord construit à partir des données stockées.
152
+ list[StorageRecord]: A list of storage records
134
153
  """
135
- # Converti en dict, avec tous les champs même s'ils sont absents
136
- raw = json_format.MessageToDict(
137
- stored_data,
138
- preserving_proto_field_name=True,
139
- always_print_fields_with_no_presence=True,
140
- )
141
- # On récupère ou on complète les champs obligatoires
142
- name = raw.get("name", default_name)
143
- dtype = raw.get("data_type", DataType.OUTPUT.name)
144
- payload = raw.get("data", {})
154
+ try:
155
+ req = data_pb2.ListRecordsRequest(
156
+ mission_id=self.mission_id,
157
+ collection=collection,
158
+ )
159
+ resp = self.exec_grpc_query("ListRecords", req)
160
+ return [self._build_record_from_proto(r) for r in resp.records]
161
+ except Exception:
162
+ logger.exception("gRPC ListRecords failed for %s", collection)
163
+ return []
145
164
 
146
- # Valide le modèle pydantic pour le champ `data`
147
- validated = self._validate_data(name, payload)
165
+ def _remove_collection(self, collection: str) -> bool:
166
+ """Delete an entire collection via gRPC.
148
167
 
149
- return StorageRecord(
150
- mission_id=self.mission_id,
151
- name=name,
152
- data_type=DataType[dtype],
153
- data=validated,
154
- creation_date=raw.get("creation_date"),
155
- update_date=raw.get("update_date"),
156
- )
168
+ Args:
169
+ collection: The unique name for the record type
170
+
171
+ Returns:
172
+ bool: True if the collection was deleted, False otherwise
173
+ """
174
+ try:
175
+ req = data_pb2.RemoveCollectionRequest(
176
+ mission_id=self.mission_id,
177
+ collection=collection,
178
+ )
179
+ self.exec_grpc_query("RemoveCollection", req)
180
+ except Exception:
181
+ logger.exception("gRPC RemoveCollection failed for %s", collection)
182
+ return False
183
+ return True
184
+
185
+ def __init__(
186
+ self,
187
+ mission_id: str,
188
+ config: dict[str, type[BaseModel]],
189
+ client_config: ClientConfig,
190
+ **kwargs, # noqa: ANN003, ARG002
191
+ ) -> None:
192
+ """Initialize the storage."""
193
+ super().__init__(mission_id=mission_id, config=config)
194
+
195
+ channel = self._init_channel(client_config)
196
+ self.stub = storage_service_pb2_grpc.StorageServiceStub(channel)
197
+ logger.debug("Channel client 'storage' initialized succesfully")
@@ -4,6 +4,7 @@ import datetime
4
4
  from abc import ABC, abstractmethod
5
5
  from enum import Enum
6
6
  from typing import Any, Literal, TypeGuard
7
+ from uuid import uuid4
7
8
 
8
9
  from pydantic import BaseModel, Field
9
10
 
@@ -24,35 +25,69 @@ class DataType(Enum):
24
25
 
25
26
 
26
27
  class StorageRecord(BaseModel):
27
- """Container for stored records with metadata."""
28
+ """A single record stored in a collection, with metadata."""
28
29
 
29
- # Metadata
30
- mission_id: str = Field(description="The ID of the mission this record is associated with")
31
- name: str = Field(description="The name of the record")
32
- creation_date: datetime.datetime | None = Field(default=None, description="The date the record was created")
33
- update_date: datetime.datetime | None = Field(default=None, description="The date the record was last updated")
34
- data_type: DataType = Field(default=DataType.OUTPUT, description="The type of data stored")
35
- # Actual data payload
36
- data: BaseModel = Field(description="The data stored in the record")
30
+ mission_id: str = Field(..., description="ID of the mission (bucket) this doc belongs to")
31
+ collection: str = Field(..., description="Logical collection name")
32
+ record_id: str = Field(..., description="Unique ID of this record in its collection")
33
+ data_type: DataType = Field(default=DataType.OUTPUT, description="Category of the data of this record")
34
+ data: BaseModel = Field(..., description="The typed payload of this record")
35
+ creation_date: datetime.datetime | None = Field(default=None, description="When this record was first created")
36
+ update_date: datetime.datetime | None = Field(default=None, description="When this record was last modified")
37
37
 
38
38
 
39
39
  class StorageStrategy(BaseStrategy, ABC):
40
- """Abstract base class for storage strategies.
40
+ """Define CRUD + list/remove-collection against a collection/record store."""
41
41
 
42
- This strategy defines how data is stored and retrieved, with
43
- type validation through registered Pydantic models.
44
- """
42
+ def _validate_data(self, collection: str, data: dict[str, Any]) -> BaseModel:
43
+ """Validate data against the model schema for the given key.
45
44
 
46
- def __init__(self, mission_id: str, config: dict[str, type[BaseModel]]) -> None:
47
- """Initialize the storage strategy.
45
+ Args:
46
+ collection: The unique name for the record type
47
+ data: The data to validate
48
+
49
+ Returns:
50
+ A validated model instance
51
+
52
+ Raises:
53
+ ValueError: If the key has no associated model or validation fails
54
+ """
55
+ model_cls = self.config.get(collection)
56
+ if not model_cls:
57
+ msg = f"No schema registered for collection '{collection}'"
58
+ raise ValueError(msg)
59
+
60
+ try:
61
+ return model_cls.model_validate(data)
62
+ except Exception as e:
63
+ msg = f"Validation failed for '{collection}': {e!s}"
64
+ raise ValueError(msg) from e
65
+
66
+ def _create_storage_record(
67
+ self,
68
+ collection: str,
69
+ record_id: str,
70
+ validated_data: BaseModel,
71
+ data_type: DataType,
72
+ ) -> StorageRecord:
73
+ """Create a storage record with metadata.
48
74
 
49
75
  Args:
50
- mission_id: The ID of the mission this strategy is associated with
51
- config: A dictionary mapping names to Pydantic model classes
76
+ collection: The unique name for the record type
77
+ record_id: The unique ID for the record
78
+ validated_data: The validated data model
79
+ data_type: The type of data
80
+
81
+ Returns:
82
+ A complete storage record with metadata
52
83
  """
53
- super().__init__(mission_id)
54
- # Schema configuration mapping keys to model classes
55
- self.config: dict[str, type[BaseModel]] = config
84
+ return StorageRecord(
85
+ mission_id=self.mission_id,
86
+ collection=collection,
87
+ record_id=record_id,
88
+ data=validated_data,
89
+ data_type=data_type,
90
+ )
56
91
 
57
92
  @staticmethod
58
93
  def _is_valid_data_type_name(value: str) -> TypeGuard[str]:
@@ -69,145 +104,162 @@ class StorageStrategy(BaseStrategy, ABC):
69
104
  The ID of the created record
70
105
  """
71
106
 
72
- def store(
73
- self,
74
- name: str,
75
- data: dict[str, Any],
76
- data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT",
77
- ) -> StorageRecord:
78
- """Store a new record in the storage.
107
+ @abstractmethod
108
+ def _read(self, collection: str, record_id: str) -> StorageRecord | None:
109
+ """Get records from storage by key.
79
110
 
80
111
  Args:
81
- name: The unique name to store the data under
82
- data: The data to store
83
- data_type: The type of data being stored (default: OUTPUT)
112
+ collection: The unique name to retrieve data for
113
+ record_id: The unique ID of the record
84
114
 
85
115
  Returns:
86
- The ID of the created record
116
+ A storage record with validated data
117
+ """
87
118
 
88
- Raises:
89
- ValueError: If the data type is invalid or if validation fails
119
+ @abstractmethod
120
+ def _update(self, collection: str, record_id: str, data: BaseModel) -> StorageRecord | None:
121
+ """Overwrite an existing record's payload.
122
+
123
+ Args:
124
+ collection: The unique name for the record type
125
+ record_id: The unique ID of the record
126
+ data: The new data to store
127
+
128
+ Returns:
129
+ StorageRecord: The modified record
90
130
  """
91
- if not self._is_valid_data_type_name(data_type):
92
- msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}"
93
- raise ValueError(msg)
94
- data_type_enum = DataType[data_type]
95
- validated_data = self._validate_data(name, {**data, "mission_id": self.mission_id})
96
- record = self._create_storage_record(name, validated_data, data_type_enum)
97
- return self._store(record)
98
131
 
99
132
  @abstractmethod
100
- def _read(self, name: str) -> StorageRecord | None:
101
- """Get records from storage by key.
133
+ def _remove(self, collection: str, record_id: str) -> bool:
134
+ """Delete a record from the storage.
102
135
 
103
136
  Args:
104
- name: The unique name to retrieve data for
137
+ collection: The unique name for the record type
138
+ record_id: The unique ID of the record
105
139
 
106
140
  Returns:
107
- A storage record with validated data
141
+ True if the deletion was successful, False otherwise
108
142
  """
109
143
 
110
- def read(self, name: str) -> StorageRecord | None:
111
- """Get records from storage by key.
144
+ @abstractmethod
145
+ def _list(self, collection: str) -> list[StorageRecord]:
146
+ """List all records in a collection.
112
147
 
113
148
  Args:
114
- name: The unique name to retrieve data for
149
+ collection: The unique name for the record type
115
150
 
116
151
  Returns:
117
- A storage record with validated data
152
+ A list of storage records
118
153
  """
119
- return self._read(name)
120
154
 
121
155
  @abstractmethod
122
- def _modify(self, name: str, data: BaseModel) -> StorageRecord | None:
123
- """Update a record in the storage.
156
+ def _remove_collection(self, collection: str) -> bool:
157
+ """Delete all records in a collection.
124
158
 
125
159
  Args:
126
- name: The unique name for the record type
127
- data: The new data to store
160
+ collection: The unique name for the record type
128
161
 
129
162
  Returns:
130
- StorageRecord: The modified record
163
+ True if the deletion was successful, False otherwise
131
164
  """
132
165
 
133
- def modify(self, name: str, data: dict[str, Any]) -> StorageRecord | None:
134
- """Update a record in the storage (overwrite all the data).
166
+ def __init__(self, mission_id: str, config: dict[str, type[BaseModel]]) -> None:
167
+ """Initialize the storage strategy.
135
168
 
136
169
  Args:
137
- name: The unique name for the record type
138
- data: The new data to store
170
+ mission_id: The ID of the mission this strategy is associated with
171
+ config: A dictionary mapping names to Pydantic model classes
172
+ """
173
+ super().__init__(mission_id)
174
+ # Schema configuration mapping keys to model classes
175
+ self.config: dict[str, type[BaseModel]] = config
176
+
177
+ def store(
178
+ self,
179
+ collection: str,
180
+ record_id: str | None,
181
+ data: dict[str, Any],
182
+ data_type: Literal["OUTPUT", "VIEW", "LOGS", "OTHER"] = "OUTPUT",
183
+ ) -> StorageRecord:
184
+ """Store a new record in the storage.
185
+
186
+ Args:
187
+ collection: The unique name for the record type
188
+ record_id: The unique ID for the record (optional)
189
+ data: The data to store
190
+ data_type: The type of data being stored (default: OUTPUT)
139
191
 
140
192
  Returns:
141
- StorageRecord: The modified record
193
+ The ID of the created record
194
+
195
+ Raises:
196
+ ValueError: If the data type is invalid or if validation fails
142
197
  """
143
- validated_data = self._validate_data(name, data)
144
- return self._modify(name, validated_data)
198
+ if not self._is_valid_data_type_name(data_type):
199
+ msg = f"Invalid data type '{data_type}'. Must be one of {list(DataType.__members__.keys())}"
200
+ raise ValueError(msg)
201
+ record_id = record_id or uuid4().hex
202
+ data_type_enum = DataType[data_type]
203
+ validated_data = self._validate_data(record_id, {**data, "mission_id": self.mission_id})
204
+ record = self._create_storage_record(collection, record_id, validated_data, data_type_enum)
205
+ return self._store(record)
145
206
 
146
- @abstractmethod
147
- def _remove(self, name: str) -> bool:
148
- """Delete a record from the storage.
207
+ def read(self, collection: str, record_id: str) -> StorageRecord | None:
208
+ """Get records from storage by key.
149
209
 
150
210
  Args:
151
- name: The unique name for the record type
211
+ collection: The unique name to retrieve data for
212
+ record_id: The unique ID of the record
152
213
 
153
214
  Returns:
154
- True if the deletion was successful, False otherwise
215
+ A storage record with validated data
216
+ """
217
+ return self._read(collection, record_id)
218
+
219
+ def update(self, collection: str, record_id: str, data: dict[str, Any]) -> StorageRecord | None:
220
+ """Validate & overwrite an existing record.
221
+
222
+ Args:
223
+ collection: The unique name for the record type
224
+ record_id: The unique ID of the record
225
+ data: The new data to store
226
+
227
+ Returns:
228
+ StorageRecord: The modified record
155
229
  """
230
+ validated_data = self._validate_data(record_id, data)
231
+ return self._update(collection, record_id, validated_data)
156
232
 
157
- def remove(self, name: str) -> bool:
233
+ def remove(self, collection: str, record_id: str) -> bool:
158
234
  """Delete a record from the storage.
159
235
 
160
236
  Args:
161
- name: The unique name for the record type
237
+ collection: The unique name for the record type
238
+ record_id: The unique ID of the record
162
239
 
163
240
  Returns:
164
241
  True if the deletion was successful, False otherwise
165
242
  """
166
- return self._remove(name)
243
+ return self._remove(collection, record_id)
167
244
 
168
- def _validate_data(self, name: str, data: dict[str, Any]) -> BaseModel:
169
- """Validate data against the model schema for the given key.
245
+ def list(self, collection: str) -> list[StorageRecord]:
246
+ """Get all records within a collection.
170
247
 
171
248
  Args:
172
- name: The unique name to get the model type for
173
- data: The data to validate
249
+ collection: The unique name for the record type
174
250
 
175
251
  Returns:
176
- A validated model instance
177
-
178
- Raises:
179
- ValueError: If the key has no associated model or validation fails
252
+ A list of storage records
180
253
  """
181
- model_cls = self.config.get(name)
182
- if not model_cls:
183
- msg = f"No model schema defined for name: {name}"
184
- raise ValueError(msg)
254
+ return self._list(collection)
185
255
 
186
- try:
187
- return model_cls.model_validate(data)
188
- except Exception as e:
189
- msg = f"Data validation failed for key '{name}': {e!s}"
190
- raise ValueError(msg) from e
191
-
192
- def _create_storage_record(
193
- self,
194
- name: str,
195
- validated_data: BaseModel,
196
- data_type: DataType,
197
- ) -> StorageRecord:
198
- """Create a storage record with metadata.
256
+ def remove_collection(self, collection: str) -> bool:
257
+ """Wipe a record clean.
199
258
 
200
259
  Args:
201
- name: The unique name for the record
202
- validated_data: The validated data model
203
- data_type: The type of data
260
+ collection: The unique name for the record type
204
261
 
205
262
  Returns:
206
- A complete storage record with metadata
263
+ True if the deletion was successful, False otherwise
207
264
  """
208
- return StorageRecord(
209
- mission_id=self.mission_id,
210
- name=name,
211
- data=validated_data,
212
- data_type=data_type,
213
- )
265
+ return self._remove_collection(collection)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digitalkin
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: SDK to build kin used in DigitalKin
5
5
  Author-email: "DigitalKin.ai" <contact@digitalkin.ai>
6
6
  License: Attribution-NonCommercial-ShareAlike 4.0 International
@@ -452,20 +452,20 @@ Classifier: License :: Other/Proprietary License
452
452
  Requires-Python: >=3.10
453
453
  Description-Content-Type: text/markdown
454
454
  License-File: LICENSE
455
- Requires-Dist: digitalkin-proto>=0.1.7
455
+ Requires-Dist: digitalkin-proto>=0.1.8
456
456
  Requires-Dist: grpcio-health-checking>=1.71.0
457
457
  Requires-Dist: grpcio-reflection>=1.71.0
458
458
  Requires-Dist: grpcio-status>=1.71.0
459
- Requires-Dist: openai>=1.70.0
460
- Requires-Dist: pydantic>=2.11.3
459
+ Requires-Dist: openai>=1.76.2
460
+ Requires-Dist: pydantic>=2.11.4
461
461
  Provides-Extra: dev
462
462
  Requires-Dist: pytest>=8.3.4; extra == "dev"
463
463
  Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
464
464
  Requires-Dist: pytest-cov>=6.1.0; extra == "dev"
465
- Requires-Dist: typos>=1.31.1; extra == "dev"
466
- Requires-Dist: ruff>=0.11.2; extra == "dev"
465
+ Requires-Dist: typos>=1.31.2; extra == "dev"
466
+ Requires-Dist: ruff>=0.11.7; extra == "dev"
467
467
  Requires-Dist: mypy>=1.15.0; extra == "dev"
468
- Requires-Dist: pyright>=1.1.398; extra == "dev"
468
+ Requires-Dist: pyright>=1.1.400; extra == "dev"
469
469
  Requires-Dist: pre-commit>=4.2.0; extra == "dev"
470
470
  Requires-Dist: bump2version>=1.0.1; extra == "dev"
471
471
  Requires-Dist: build>=1.2.2; extra == "dev"
@@ -7,7 +7,7 @@ base_server/mock/__init__.py,sha256=YZFT-F1l_TpvJYuIPX-7kTeE1CfOjhx9YmNRXVoi-jQ,
7
7
  base_server/mock/mock_pb2.py,sha256=sETakcS3PAAm4E-hTCV1jIVaQTPEAIoVVHupB8Z_k7Y,1843
8
8
  base_server/mock/mock_pb2_grpc.py,sha256=BbOT70H6q3laKgkHfOx1QdfmCS_HxCY4wCOX84YAdG4,3180
9
9
  digitalkin/__init__.py,sha256=7LLBAba0th-3SGqcpqFO-lopWdUkVLKzLZiMtB-mW3M,162
10
- digitalkin/__version__.py,sha256=sq3ZYGvIa5rczwcukAFcGJdR0MZ8h0cIPYmDlhbjOP4,190
10
+ digitalkin/__version__.py,sha256=hDLYpdit14gwOhkz3Wcb9mCY0_Pbep3DECM9bgKB1pY,190
11
11
  digitalkin/logger.py,sha256=9cDgyJV2QXXT8F--xRODFlZyDgjuTTXNdpCU3GdqCsk,382
12
12
  digitalkin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  digitalkin/grpc_servers/__init__.py,sha256=0cJBlwipSmFdXkyH3T0i6OJ1WpAtNsZgYX7JaSnkbtg,804
@@ -62,18 +62,18 @@ digitalkin/services/snapshot/__init__.py,sha256=Uzlnzo0CYlSpVsdiI37hW7xQk8hu3YA1
62
62
  digitalkin/services/snapshot/default_snapshot.py,sha256=Mb8QwWRsHh9I_tN0ln_ZiFa1QCZxOVWmuVLemQOTWpc,1058
63
63
  digitalkin/services/snapshot/snapshot_strategy.py,sha256=B1TU3V_k9A-OdqBkdyc41-ihnrW5Btcwd1KyQdHT46A,898
64
64
  digitalkin/services/storage/__init__.py,sha256=T-ocYLLphudkQgzvG47jBOm5GQsRFRIGA88y7Ur4akg,341
65
- digitalkin/services/storage/default_storage.py,sha256=bHNPm8nLvytKqKP2ntLkikvqH1qDKOwofrStVJH6PJg,7765
66
- digitalkin/services/storage/grpc_storage.py,sha256=eSadiI3JuveItx8LdiC3GbjbjnB8GmPqTVxw1uA1-9E,5809
67
- digitalkin/services/storage/storage_strategy.py,sha256=vGo4aYkEp_GZV11m7vd-xY_Z3gVa5K0gMTzbj2Au_3o,6600
65
+ digitalkin/services/storage/default_storage.py,sha256=qzLPrND92NR9hB7Ok6BF3Yxot14Efa_CHIvVte6kLsU,7817
66
+ digitalkin/services/storage/grpc_storage.py,sha256=uFWxTsh2WXx2MY9W_dLSgpymZaLamN81RhyTDlhl_K8,6937
67
+ digitalkin/services/storage/storage_strategy.py,sha256=Om05zryRfTwlI6l_H_eKM_6ijhuRQbV5Yltn2cBhh2A,8633
68
68
  digitalkin/utils/__init__.py,sha256=sJnY-ZUgsjMfojAjONC1VN14mhgIDnzyOlGkw21rRnM,28
69
69
  digitalkin/utils/arg_parser.py,sha256=3YyI6oZhhrlTmPTrzlwpQzbCNWDFAT3pggcLxNtJoc0,4388
70
70
  digitalkin/utils/llm_ready_schema.py,sha256=JjMug_lrQllqFoanaC091VgOqwAd-_YzcpqFlS7p778,2375
71
- digitalkin-0.2.7.dist-info/licenses/LICENSE,sha256=Ies4HFv2r2hzDRakJYxk3Y60uDFLiG-orIgeTpstnIo,20327
71
+ digitalkin-0.2.8.dist-info/licenses/LICENSE,sha256=Ies4HFv2r2hzDRakJYxk3Y60uDFLiG-orIgeTpstnIo,20327
72
72
  modules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
73
  modules/minimal_llm_module.py,sha256=W-E3OrRbAsRJ6hvSeTU8pzmacdJC_PbcWfDapRv5A1A,5617
74
- modules/storage_module.py,sha256=bu52lW4RFcWB8VFDhrpBFfCaTSkVL6so3zrkfW4LO9E,6270
74
+ modules/storage_module.py,sha256=528tfWyRw3q5h0lUlP9Or8E7m3AnnyXezXgwisXd8BI,6399
75
75
  modules/text_transform_module.py,sha256=1KaA7abwxltKKtbmiW1rkkIK3BTYFPegUq54px0LOQs,7277
76
- digitalkin-0.2.7.dist-info/METADATA,sha256=1-9MQvIcY6e4S5Cfzea9Nk2lHFx7coGQZ935DzJ00zg,29125
77
- digitalkin-0.2.7.dist-info/WHEEL,sha256=ck4Vq1_RXyvS4Jt6SI0Vz6fyVs4GWg7AINwpsaGEgPE,91
78
- digitalkin-0.2.7.dist-info/top_level.txt,sha256=5_5e35inSM5YfWNZE21p5wGBojiVtQQML_WzbEk4BRU,31
79
- digitalkin-0.2.7.dist-info/RECORD,,
76
+ digitalkin-0.2.8.dist-info/METADATA,sha256=8lmICvmb-B3owpEGkyFk0vmIuVNabxkYORbaoWS5fqU,29125
77
+ digitalkin-0.2.8.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
78
+ digitalkin-0.2.8.dist-info/top_level.txt,sha256=5_5e35inSM5YfWNZE21p5wGBojiVtQQML_WzbEk4BRU,31
79
+ digitalkin-0.2.8.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.0.0)
2
+ Generator: setuptools (80.3.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
modules/storage_module.py CHANGED
@@ -119,7 +119,12 @@ class ExampleModule(ArchetypeModule[ExampleInput, ExampleOutput, ExampleSetup, E
119
119
  )
120
120
 
121
121
  # Store the output data in storage
122
- storage_id = self.storage.store("example_outputs", output_data.model_dump(), data_type="OUTPUT")
122
+ storage_id = self.storage.store(
123
+ collection="example",
124
+ record_id=f"example_outputs",
125
+ data=output_data.model_dump(),
126
+ data_type="OUTPUT"
127
+ )
123
128
 
124
129
  logger.info("Stored output data with ID: %s", storage_id)
125
130
 
@@ -159,7 +164,7 @@ async def test_module() -> None:
159
164
 
160
165
  # Check the storage
161
166
  if module.status == ModuleStatus.STOPPED:
162
- result: StorageRecord = module.storage.read("example_outputs")
167
+ result: StorageRecord = module.storage.read("example", "example_outputs")
163
168
  if result:
164
169
  pass
165
170
 
@@ -170,10 +175,10 @@ def test_storage_directly() -> None:
170
175
  storage = ServicesConfig().storage(mission_id="test-mission", config={"test_table": ExampleStorage})
171
176
 
172
177
  # Create a test record
173
- storage.store("test_table", {"test_key": "test_value"}, "OUTPUT")
178
+ storage.store("example", "test_table", {"test_key": "test_value"}, "OUTPUT")
174
179
 
175
180
  # Retrieve the record
176
- retrieved = storage.read("test_table")
181
+ retrieved = storage.read("example", "test_table")
177
182
 
178
183
  if retrieved:
179
184
  pass