in-layers-data 0.4.20__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.
in_layers/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Namespace package for in_layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pkgutil
6
+
7
+ __path__ = pkgutil.extend_path(__path__, __name__) # type: ignore[name-defined]
@@ -0,0 +1,21 @@
1
+ from . import services
2
+ from .protocols import (
3
+ BackendConfig,
4
+ DataNamespace,
5
+ DynamoDBBackendConfig,
6
+ InLayersDataConfig,
7
+ MongoBackendConfig,
8
+ SupportedBackend,
9
+ )
10
+
11
+ name = DataNamespace.root.value
12
+
13
+ __all__ = [
14
+ "BackendConfig",
15
+ "DynamoDBBackendConfig",
16
+ "InLayersDataConfig",
17
+ "MongoBackendConfig",
18
+ "SupportedBackend",
19
+ "name",
20
+ "services",
21
+ ]
File without changes
@@ -0,0 +1,5 @@
1
+ """DynamoDB backend for InLayers models."""
2
+
3
+ from .services import DynamoDBBackend
4
+
5
+ __all__ = ["DynamoDBBackend"]
@@ -0,0 +1,108 @@
1
+ """Pure functional utilities for DynamoDB query conversion and data formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from datetime import datetime
8
+ from decimal import Decimal
9
+ from typing import Any
10
+
11
+ from in_layers.core.models.protocols import ModelDefinition
12
+
13
+
14
+ def get_table_name_for_model(
15
+ environment: str, model_definition: ModelDefinition
16
+ ) -> str:
17
+ """Generate a DynamoDB table name from a model definition."""
18
+ name = model_definition.plural_name.replace("@", "").replace("/", "-")
19
+ # Convert to kebab-case: insert hyphens before uppercase letters (except first)
20
+ # and handle sequences of uppercase letters
21
+ name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
22
+ name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
23
+ return f"{name}-{environment}".lower()
24
+
25
+
26
+ def convert_all_floats_to_decimals(obj):
27
+ """
28
+ Recursively convert all float values in a nested structure to Decimal.
29
+ This is useful for preparing data for DynamoDB, which requires numbers to be
30
+ represented as Decimals to avoid precision issues.
31
+
32
+ Args:
33
+ obj: The input object, which can be a dict, list, float, or other types.
34
+ Returns:
35
+ The modified object with all float values converted to Decimal.
36
+ """
37
+ if isinstance(obj, float):
38
+ return Decimal(str(obj))
39
+ elif isinstance(obj, dict):
40
+ return {k: convert_all_floats_to_decimals(v) for k, v in obj.items()}
41
+ elif isinstance(obj, list):
42
+ return [convert_all_floats_to_decimals(item) for item in obj]
43
+ else:
44
+ return obj
45
+
46
+
47
+ def format_for_dynamodb(data: Mapping[str, Any]) -> dict[str, Any]:
48
+ """Format data for DynamoDB storage.
49
+
50
+ DynamoDB natively handles:
51
+ - Strings
52
+ - Numbers (int, float, Decimal)
53
+ - Binary data
54
+ - Boolean
55
+ - Null
56
+ - Lists
57
+ - Maps (dicts)
58
+ - Sets (string sets, number sets, binary sets)
59
+
60
+ The boto3 DynamoDBDocumentClient will handle conversion automatically,
61
+ but we ensure datetime objects are converted to ISO format strings.
62
+ """
63
+ result = dict(data)
64
+ # Convert datetime objects to ISO format strings for DynamoDB
65
+
66
+ for key, value in result.items():
67
+ if isinstance(value, datetime):
68
+ result[key] = value.isoformat()
69
+ return convert_all_floats_to_decimals(result)
70
+
71
+
72
+ def from_dynamodb(item: dict[str, Any] | None) -> dict[str, Any]:
73
+ """Convert a DynamoDB item to a plain dictionary.
74
+
75
+ The boto3 DynamoDBDocumentClient already converts AttributeValue format
76
+ to native Python types, so this is mainly for consistency and future-proofing.
77
+ """
78
+ if item is None:
79
+ return {}
80
+ return dict(item)
81
+
82
+
83
+ def build_scan_params(
84
+ table_name: str, exclusive_start_key: dict[str, Any] | None = None
85
+ ) -> dict[str, Any]:
86
+ """Build parameters for a DynamoDB Scan operation."""
87
+ params: dict[str, Any] = {
88
+ "TableName": table_name,
89
+ }
90
+ if exclusive_start_key:
91
+ params["ExclusiveStartKey"] = exclusive_start_key
92
+ return params
93
+
94
+
95
+ def split_array_into_batches(array: list[Any], max_batch_size: int) -> list[list[Any]]:
96
+ """Split an array into batches of maximum size.
97
+
98
+ DynamoDB has limits on batch operations (e.g., BatchWriteItem max 25 items).
99
+ """
100
+ if not isinstance(array, list):
101
+ raise ValueError("Input must be a list")
102
+ if max_batch_size < 1:
103
+ raise ValueError("max_batch_size must be at least 1")
104
+
105
+ batches = []
106
+ for i in range(0, len(array), max_batch_size):
107
+ batches.append(array[i : i + max_batch_size])
108
+ return batches
@@ -0,0 +1,395 @@
1
+ """DynamoDB backend implementation for InLayers models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from box import Box
10
+ from in_layers.core.models.backends import (
11
+ _apply_sort,
12
+ _apply_take,
13
+ _matches_query_tokens,
14
+ )
15
+ from in_layers.core.models.protocols import (
16
+ InLayersModel,
17
+ ModelSearch,
18
+ ModelSearchResult,
19
+ PrimaryKeyType,
20
+ )
21
+
22
+ from in_layers.data.protocols import DynamoDBBackendConfig
23
+
24
+ from .libs import (
25
+ format_for_dynamodb,
26
+ from_dynamodb,
27
+ get_table_name_for_model,
28
+ split_array_into_batches,
29
+ )
30
+
31
+ # DynamoDB batch operation limits
32
+ MAX_BATCH_WRITE_SIZE = 25
33
+ SCAN_RETURN_THRESHOLD = 1000
34
+
35
+
36
+ class DynamoDBBackend:
37
+ """DynamoDB backend implementation."""
38
+
39
+ def __init__(self, context, config: DynamoDBBackendConfig):
40
+ self.__context = context
41
+ self.__config = config
42
+ self.__client: Any = None
43
+ self.__table_client: Any = None
44
+
45
+ @staticmethod
46
+ def create_unique_connection_string(config: DynamoDBBackendConfig) -> str:
47
+ """Create a unique connection string from config."""
48
+ region = config.region or "us-east-1"
49
+ endpoint_url = config.endpoint_url or ""
50
+
51
+ parts = [f"region={region}"]
52
+ if endpoint_url:
53
+ parts.append(f"endpoint={endpoint_url}")
54
+
55
+ return "|".join(parts)
56
+
57
+ def __get_config_value(self, key: str, default_value: Any = None) -> Any:
58
+ if key in self.__config:
59
+ return self.__config[key]
60
+ return default_value
61
+
62
+ def __connect(self) -> None:
63
+ """Connect to DynamoDB (private method)."""
64
+ # Use boto3 from config if provided (for testing), otherwise import it
65
+ if self.__get_config_value("boto3") is not None:
66
+ boto3 = self.__get_config_value("boto3")
67
+ else:
68
+ import boto3 # pragma: no cover # noqa: PLC0415
69
+
70
+ region = self.__get_config_value("region")
71
+ endpoint_url = self.__get_config_value("endpoint_url")
72
+ aws_access_key_id = self.__get_config_value("aws_access_key_id")
73
+ aws_secret_access_key = self.__get_config_value("aws_secret_access_key")
74
+ client_kwargs: dict[str, Any] = {}
75
+ if region:
76
+ client_kwargs["region_name"] = region
77
+ if endpoint_url:
78
+ client_kwargs["endpoint_url"] = endpoint_url
79
+ if aws_access_key_id:
80
+ client_kwargs["aws_access_key_id"] = aws_access_key_id
81
+ if aws_secret_access_key:
82
+ client_kwargs["aws_secret_access_key"] = aws_secret_access_key
83
+
84
+ # Create DynamoDB client
85
+ self.__client = boto3.client("dynamodb", **client_kwargs)
86
+
87
+ # Create DynamoDB resource for easier table operations
88
+ dynamodb_resource = boto3.resource("dynamodb", **client_kwargs)
89
+ self.__table_client = dynamodb_resource
90
+
91
+ def __disconnect(self) -> None:
92
+ """Disconnect from DynamoDB (private method)."""
93
+ # boto3 clients don't need explicit closing, but we can clear references
94
+ self.__client = None
95
+ self.__table_client = None
96
+
97
+ def __ensure_connected(self) -> None:
98
+ """Ensure DynamoDB connection is established."""
99
+ if self.__client is None:
100
+ self.__connect()
101
+
102
+ def get_raw_client(self) -> Any:
103
+ self.__ensure_connected()
104
+ return self.__table_client
105
+
106
+ def get_backend_name(self) -> str:
107
+ return "dynamodb"
108
+
109
+ def create(self, model: InLayersModel, data: Mapping) -> Mapping:
110
+ """Create a new item in DynamoDB."""
111
+ self.__ensure_connected()
112
+
113
+ table_name = get_table_name_for_model(
114
+ self.__context.config.environment, model.get_model_definition()
115
+ )
116
+ table = self.__table_client.Table(table_name)
117
+
118
+ payload = dict(data)
119
+ formatted = format_for_dynamodb(payload)
120
+
121
+ pk_name = model.get_primary_key_name()
122
+ pk_value = formatted.get(pk_name)
123
+ if pk_value is None:
124
+ pk_value = str(uuid4())
125
+ formatted[pk_name] = pk_value
126
+
127
+ # Ensure primary key is a string (DynamoDB requirement)
128
+ formatted[pk_name] = str(pk_value)
129
+
130
+ # Put item in DynamoDB
131
+ table.put_item(Item=formatted)
132
+ return formatted
133
+
134
+ def retrieve(self, model: InLayersModel, id: PrimaryKeyType) -> Mapping | None:
135
+ """Retrieve an item by ID."""
136
+ self.__ensure_connected()
137
+
138
+ table_name = get_table_name_for_model(
139
+ self.__context.config.environment, model.get_model_definition()
140
+ )
141
+ table = self.__table_client.Table(table_name)
142
+
143
+ pk_name = model.get_primary_key_name()
144
+ key = {pk_name: str(id)}
145
+
146
+ response = table.get_item(Key=key)
147
+ item = response.get("Item")
148
+ if not item:
149
+ return None
150
+
151
+ return from_dynamodb(item)
152
+
153
+ def update(
154
+ self, model: InLayersModel, id: PrimaryKeyType, data: Mapping
155
+ ) -> Mapping:
156
+ """Update an item by ID.
157
+
158
+ Supports partial updates - only the fields provided in data will be updated.
159
+ Returns the full merged object (original + updates).
160
+ """
161
+ self.__ensure_connected()
162
+
163
+ table_name = get_table_name_for_model(
164
+ self.__context.config.environment, model.get_model_definition()
165
+ )
166
+ table = self.__table_client.Table(table_name)
167
+
168
+ pk_name = model.get_primary_key_name()
169
+ key = {pk_name: str(id)}
170
+
171
+ # Check if item exists
172
+ existing = table.get_item(Key=key)
173
+ if "Item" not in existing:
174
+ raise KeyError(f"Instance with id {id!r} not found")
175
+
176
+ payload = dict(data)
177
+ formatted = format_for_dynamodb(payload)
178
+
179
+ # Ensure primary key field remains consistent
180
+ formatted[pk_name] = str(id)
181
+
182
+ # Build UpdateExpression for partial update
183
+ # DynamoDB requires SET expressions for each attribute
184
+ update_expressions = []
185
+ expression_attribute_names = {}
186
+ expression_attribute_values = {}
187
+
188
+ for attr_name, attr_value in formatted.items():
189
+ # Skip the primary key as it's in the Key parameter
190
+ if attr_name == pk_name:
191
+ continue
192
+
193
+ # Use attribute name placeholders to handle reserved words
194
+ name_placeholder = f"#attr_{attr_name}"
195
+ value_placeholder = f":val_{attr_name}"
196
+
197
+ expression_attribute_names[name_placeholder] = attr_name
198
+ expression_attribute_values[value_placeholder] = attr_value
199
+ update_expressions.append(f"{name_placeholder} = {value_placeholder}")
200
+
201
+ if not update_expressions:
202
+ # No fields to update (only primary key was provided)
203
+ # Just return the existing item
204
+ return from_dynamodb(existing["Item"])
205
+
206
+ # Build the update expression
207
+ update_expression = f"SET {', '.join(update_expressions)}"
208
+
209
+ # Perform partial update using update_item
210
+ table.update_item(
211
+ Key=key,
212
+ UpdateExpression=update_expression,
213
+ ExpressionAttributeNames=expression_attribute_names,
214
+ ExpressionAttributeValues=expression_attribute_values,
215
+ )
216
+
217
+ # Retrieve the updated item to return the full merged object
218
+ updated = table.get_item(Key=key)
219
+ if "Item" not in updated:
220
+ raise KeyError(f"Instance with id {id!r} not found after update")
221
+
222
+ return from_dynamodb(updated["Item"])
223
+
224
+ def delete(self, model: InLayersModel, id: PrimaryKeyType) -> None:
225
+ """Delete an item by ID."""
226
+ self.__ensure_connected()
227
+
228
+ table_name = get_table_name_for_model(
229
+ self.__context.config.environment, model.get_model_definition()
230
+ )
231
+ table = self.__table_client.Table(table_name)
232
+
233
+ pk_name = model.get_primary_key_name()
234
+ key = {pk_name: str(id)}
235
+
236
+ table.delete_item(Key=key)
237
+
238
+ def search(self, model: InLayersModel, query: ModelSearch) -> ModelSearchResult:
239
+ """Search for items matching the query.
240
+
241
+ Note: DynamoDB Scan operations are expensive and should be used sparingly.
242
+ For production use, consider using Query operations with Global Secondary Indexes (GSI)
243
+ or Local Secondary Indexes (LSI) for better performance.
244
+
245
+ This implementation continues scanning across pages until:
246
+ - Threshold is met (SCAN_RETURN_THRESHOLD if no take specified)
247
+ - No more keys (LastEvaluatedKey is null)
248
+ - Take limit is reached (if take is specified)
249
+ """
250
+ self.__ensure_connected()
251
+
252
+ table_name = get_table_name_for_model(
253
+ self.__context.config.environment, model.get_model_definition()
254
+ )
255
+ table = self.__table_client.Table(table_name)
256
+
257
+ # Start recursive scanning
258
+ result = self._do_search_until_threshold_or_no_last_evaluated_key(
259
+ table, query, []
260
+ )
261
+
262
+ return Box(instances=result["instances"], page=result["page"])
263
+
264
+ def _do_search_until_threshold_or_no_last_evaluated_key(
265
+ self,
266
+ table: Any,
267
+ search: ModelSearch,
268
+ old_instances_found: list[dict[str, Any]],
269
+ ) -> dict[str, Any]:
270
+ """Recursively scan DynamoDB until threshold is met or no more keys.
271
+
272
+ This matches the TypeScript implementation's behavior of continuing
273
+ to scan across pages until enough filtered results are found.
274
+ """
275
+ # Build scan parameters
276
+ scan_kwargs: dict[str, Any] = {}
277
+ # Ensure query is always a list (handle None case)
278
+ query = getattr(search, "query", []) or []
279
+ take = getattr(search, "take", None)
280
+ sort = getattr(search, "sort", None)
281
+ if getattr(search, "page", None):
282
+ scan_kwargs["ExclusiveStartKey"] = search.page
283
+
284
+ # Execute scan
285
+ response = table.scan(**scan_kwargs)
286
+ items = response.get("Items", [])
287
+
288
+ # Convert DynamoDB items to plain dicts
289
+ unfiltered = [from_dynamodb(item) for item in items]
290
+
291
+ # Apply filtering using the same logic as MemoryBackend
292
+ filtered = [r for r in unfiltered if _matches_query_tokens(r, query)]
293
+
294
+ # Combine with previously found instances
295
+ all_filtered = filtered + old_instances_found
296
+
297
+ # Determine threshold
298
+ using_take = take is not None and take > 0
299
+ threshold = take if using_take else SCAN_RETURN_THRESHOLD
300
+
301
+ # Get pagination key
302
+ last_evaluated_key = response.get("LastEvaluatedKey")
303
+
304
+ # Check stopping conditions:
305
+ # 1. We have enough results (more than threshold)
306
+ # 2. No more keys to evaluate
307
+ # Note: TypeScript uses > (strictly greater), meaning we continue if we have exactly 'threshold' items
308
+ stop_for_threshold = len(all_filtered) > threshold
309
+ stop_for_no_more = last_evaluated_key is None
310
+
311
+ if stop_for_threshold or stop_for_no_more:
312
+ # Apply sorting and take limit
313
+ sorted_instances = _apply_sort(all_filtered, sort)
314
+ # Apply take limit (use original take value, not threshold)
315
+ limited_instances = _apply_take(sorted_instances, take)
316
+
317
+ # Return page: null when using take, otherwise return LastEvaluatedKey
318
+ page = None if using_take else last_evaluated_key
319
+
320
+ return {
321
+ "instances": [dict(x) for x in limited_instances],
322
+ "page": page,
323
+ }
324
+
325
+ # Continue scanning with the new page key
326
+ # Create a new ModelSearch with updated page (frozen dataclass requires new instance)
327
+ new_query = ModelSearch(
328
+ query=query,
329
+ take=take,
330
+ sort=sort,
331
+ page=last_evaluated_key,
332
+ )
333
+ return self._do_search_until_threshold_or_no_last_evaluated_key(
334
+ table, new_query, all_filtered
335
+ )
336
+
337
+ def bulk_insert(self, model: InLayersModel, data: list[Mapping]) -> None:
338
+ """Bulk insert items."""
339
+ self.__ensure_connected()
340
+
341
+ table_name = get_table_name_for_model(
342
+ self.__context.config.environment, model.get_model_definition()
343
+ )
344
+ table = self.__table_client.Table(table_name)
345
+ pk_name = model.get_primary_key_name()
346
+
347
+ # Prepare items
348
+ items = []
349
+ for item in data:
350
+ payload = dict(item)
351
+ formatted = format_for_dynamodb(payload)
352
+
353
+ pk_value = formatted.get(pk_name)
354
+ if pk_value is None:
355
+
356
+ pk_value = str(uuid4())
357
+ formatted[pk_name] = pk_value
358
+
359
+ formatted[pk_name] = str(pk_value)
360
+ items.append(formatted)
361
+
362
+ # Split into batches (DynamoDB BatchWriteItem limit is 25)
363
+ batches = split_array_into_batches(items, MAX_BATCH_WRITE_SIZE)
364
+
365
+ # Write batches
366
+ for batch in batches:
367
+ with table.batch_writer() as writer:
368
+ for item in batch:
369
+ writer.put_item(Item=item)
370
+
371
+ def bulk_delete(self, model: InLayersModel, ids: list[PrimaryKeyType]) -> None:
372
+ """Bulk delete items by IDs."""
373
+ self.__ensure_connected()
374
+
375
+ table_name = get_table_name_for_model(
376
+ self.__context.config.environment, model.get_model_definition()
377
+ )
378
+ table = self.__table_client.Table(table_name)
379
+ pk_name = model.get_primary_key_name()
380
+
381
+ # Prepare keys
382
+ keys = [{pk_name: str(id)} for id in ids]
383
+
384
+ # Split into batches
385
+ batches = split_array_into_batches(keys, MAX_BATCH_WRITE_SIZE)
386
+
387
+ # Delete batches
388
+ for batch in batches:
389
+ with table.batch_writer() as writer:
390
+ for key in batch:
391
+ writer.delete_item(Key=key)
392
+
393
+ def dispose(self) -> None:
394
+ """Clean up resources."""
395
+ self.__disconnect()
File without changes
@@ -0,0 +1,251 @@
1
+ """Pure functional utilities for MongoDB query conversion and data formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from datetime import datetime
8
+ from decimal import Decimal
9
+ from typing import Any
10
+
11
+ from in_layers.core.models.protocols import (
12
+ BooleanQuery,
13
+ DatastoreValueType,
14
+ EqualitySymbol,
15
+ ModelDefinition,
16
+ PropertyQuery,
17
+ QueryTokens,
18
+ )
19
+
20
+
21
+ def get_collection_name_for_model(model_definition: ModelDefinition) -> str:
22
+ """Generate a MongoDB collection name from a model definition."""
23
+ name = model_definition.plural_name.replace("@", "").replace("/", "-")
24
+ # Convert to kebab-case: insert hyphens before uppercase letters (except first)
25
+ # and handle sequences of uppercase letters
26
+ name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
27
+ name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
28
+ return f"{name}".lower()
29
+
30
+
31
+ def escape_regex(s: str) -> str:
32
+ """Escape special regex characters in a string."""
33
+ return re.escape(s)
34
+
35
+
36
+ def build_string_pattern(
37
+ raw: str, starts_with: bool, ends_with: bool, includes: bool
38
+ ) -> str:
39
+ """Build a regex pattern for string matching."""
40
+ escaped = escape_regex(raw)
41
+ if starts_with:
42
+ return f"^{escaped}"
43
+ if ends_with:
44
+ return f"{escaped}$"
45
+ if includes:
46
+ return escaped
47
+ # default: exact match
48
+ return f"^{escaped}$"
49
+
50
+
51
+ def regex_object_for_pattern(pattern: str, case_sensitive: bool) -> dict[str, Any]:
52
+ """Create a MongoDB regex object from a pattern."""
53
+ if case_sensitive:
54
+ return {"$regex": pattern}
55
+ return {"$regex": pattern, "$options": "i"}
56
+
57
+
58
+ def build_mongo_find_value(query: PropertyQuery) -> dict[str, Any]: # noqa: PLR0911
59
+ """Convert a PropertyQuery to a MongoDB query value."""
60
+ value = query.value
61
+ if value is None:
62
+ return {query.key: None}
63
+
64
+ # Handle date objects
65
+ if hasattr(value, "isoformat"): # datetime objects
66
+ return {query.key: value}
67
+
68
+ if query.value_type == DatastoreValueType.string:
69
+ case_sensitive = bool(query.options.case_sensitive)
70
+ starts_with = bool(query.options.starts_with)
71
+ ends_with = bool(query.options.ends_with)
72
+ includes = bool(query.options.includes)
73
+
74
+ if query.equality_symbol not in (EqualitySymbol.eq, EqualitySymbol.ne):
75
+ raise ValueError(
76
+ f"Symbol {query.equality_symbol} is unhandled for string type"
77
+ )
78
+
79
+ raw = str(value)
80
+ pattern = build_string_pattern(raw, starts_with, ends_with, includes)
81
+ regex_obj = regex_object_for_pattern(pattern, case_sensitive)
82
+
83
+ if query.equality_symbol == EqualitySymbol.ne:
84
+ is_plain_exact = (
85
+ not starts_with and not ends_with and not includes and case_sensitive
86
+ )
87
+ if is_plain_exact:
88
+ return {query.key: {"$ne": raw}}
89
+ return {query.key: {"$not": regex_obj}}
90
+
91
+ use_regex = starts_with or ends_with or includes or not case_sensitive
92
+ if use_regex:
93
+ return {query.key: regex_obj}
94
+ return {query.key: raw}
95
+
96
+ if query.value_type == DatastoreValueType.number:
97
+ equality_symbol_to_mongo = {
98
+ EqualitySymbol.eq: "$eq",
99
+ EqualitySymbol.gt: "$gt",
100
+ EqualitySymbol.gte: "$gte",
101
+ EqualitySymbol.lt: "$lt",
102
+ EqualitySymbol.lte: "$lte",
103
+ EqualitySymbol.ne: "$ne",
104
+ }
105
+ mongo_symbol = equality_symbol_to_mongo.get(query.equality_symbol)
106
+ if not mongo_symbol:
107
+ raise ValueError(f"Symbol {query.equality_symbol} is unhandled")
108
+ return {query.key: {mongo_symbol: query.value}}
109
+
110
+ return {query.key: value}
111
+
112
+
113
+ def threeitize(
114
+ data: list[QueryTokens],
115
+ ) -> list[tuple[QueryTokens, BooleanQuery, QueryTokens]]:
116
+ """Convert a list of tokens into three-tuples of (left, link, right)."""
117
+ if len(data) in (0, 1):
118
+ return []
119
+ if len(data) % 2 == 0:
120
+ raise ValueError("Must be an odd number of 3 or greater.")
121
+ three = (data[0], _as_link(data[1]), data[2])
122
+ rest = data[2:]
123
+ more = threeitize(rest)
124
+ return [three, *more]
125
+
126
+
127
+ def _as_link(value: QueryTokens) -> BooleanQuery:
128
+ """Convert a token to a BooleanQuery link."""
129
+ if value in {"AND", "OR"}:
130
+ return value
131
+ raise ValueError("Must have AND/OR between statements")
132
+
133
+
134
+ def process_mongo_array(
135
+ tokens: list[QueryTokens],
136
+ ) -> dict[str, Any]:
137
+ """Process an array of query tokens into a MongoDB query."""
138
+ # If we don't have any AND/OR, it's all an AND
139
+ if all(t != "AND" and t != "OR" for t in tokens): # noqa: PLR1714
140
+ return {"$and": [handle_mongo_query(t) for t in tokens]}
141
+
142
+ # Process with threeitize
143
+ threes = threeitize(tokens)
144
+ threes.reverse()
145
+ result: dict[str, Any] = {}
146
+ for a, link, b in threes:
147
+ a_query = handle_mongo_query(a)
148
+ if result:
149
+ result = {f"${link.lower()}": [a_query, result]}
150
+ else:
151
+ b_query = handle_mongo_query(b)
152
+ result = {f"${link.lower()}": [a_query, b_query]}
153
+ return result
154
+
155
+
156
+ def handle_mongo_query(token: QueryTokens | list[QueryTokens]) -> dict[str, Any]:
157
+ """Convert a query token to a MongoDB query object."""
158
+ # Handle list of tokens (the main query)
159
+ if isinstance(token, list):
160
+ return process_mongo_array(token)
161
+
162
+ # Check by shape (duck typing) rather than isinstance
163
+ # PropertyQuery has type="property" and value attribute
164
+ if (
165
+ hasattr(token, "type")
166
+ and getattr(token, "type", None) == "property"
167
+ and hasattr(token, "value")
168
+ ):
169
+ return build_mongo_find_value(token)
170
+
171
+ # DatesBeforeQuery has type="datesBefore" and date attribute
172
+ if (
173
+ hasattr(token, "type")
174
+ and getattr(token, "type", None) == "datesBefore"
175
+ and hasattr(token, "date")
176
+ ):
177
+ date_value = token.date
178
+ if (
179
+ hasattr(token, "value_type") and token.value_type == DatastoreValueType.date
180
+ ) and isinstance(date_value, str):
181
+ # Convert string to datetime if needed
182
+ date_value = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
183
+ operator = (
184
+ "$lte"
185
+ if (hasattr(token, "options") and token.options.equal_to_and_before)
186
+ else "$lt"
187
+ )
188
+ return {token.key: {operator: date_value}}
189
+
190
+ # DatesAfterQuery has type="datesAfter" and date attribute
191
+ if (
192
+ hasattr(token, "type")
193
+ and getattr(token, "type", None) == "datesAfter"
194
+ and hasattr(token, "date")
195
+ ):
196
+ date_value = token.date
197
+ if (
198
+ hasattr(token, "value_type") and token.value_type == DatastoreValueType.date
199
+ ) and isinstance(date_value, str):
200
+ date_value = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
201
+ operator = (
202
+ "$gte"
203
+ if (hasattr(token, "options") and token.options.equal_to_and_after)
204
+ else "$gt"
205
+ )
206
+ return {token.key: {operator: date_value}}
207
+
208
+ raise ValueError(f"Unhandled query token {token}")
209
+
210
+
211
+ def to_mongo(query: list[QueryTokens]) -> list[dict[str, Any]]:
212
+ """Convert a query list to MongoDB aggregation pipeline stages."""
213
+ if not query:
214
+ return [{"$match": {}}]
215
+ # Pass the list directly to handle_mongo_query, which will process it as an array
216
+ match_query = handle_mongo_query(query)
217
+ return [{"$match": match_query}]
218
+
219
+
220
+ def convert_decimals_to_float(value: Any) -> Any:
221
+ """Recursively convert Decimal values to float for MongoDB serialization.
222
+
223
+ MongoDB cannot serialize Decimal objects, so we need to convert them to float.
224
+ This function walks through nested structures (dicts, lists, etc.) and converts
225
+ any Decimal values it finds.
226
+ """
227
+ if isinstance(value, Decimal):
228
+ return float(value)
229
+ if isinstance(value, dict):
230
+ return {k: convert_decimals_to_float(v) for k, v in value.items()}
231
+ if isinstance(value, list):
232
+ return [convert_decimals_to_float(item) for item in value]
233
+ if isinstance(value, tuple):
234
+ return tuple(convert_decimals_to_float(item) for item in value)
235
+ return value
236
+
237
+
238
+ def format_for_mongo(data: Mapping[str, Any]) -> dict[str, Any]:
239
+ """Format data for MongoDB storage, converting dates, decimals, and other types."""
240
+ result = dict(data)
241
+ # Convert datetime objects (they're already in the right format for MongoDB)
242
+ # In the TypeScript version, this iterates over model properties to find Datetime types
243
+ # For Python, we'll preserve datetime objects as-is (MongoDB handles them natively)
244
+ # and convert string ISO dates if they're clearly datetime values
245
+ for key, value in result.items():
246
+ if isinstance(value, datetime):
247
+ result[key] = value
248
+ else:
249
+ # Convert any Decimal values to float (recursively)
250
+ result[key] = convert_decimals_to_float(value)
251
+ return result
@@ -0,0 +1,251 @@
1
+ """MongoDB backend implementation for InLayers models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from box import Box
10
+ from in_layers.core.models.protocols import (
11
+ InLayersModel,
12
+ ModelSearch,
13
+ ModelSearchResult,
14
+ PrimaryKeyType,
15
+ SortOrder,
16
+ )
17
+
18
+ from in_layers.data.protocols import MongoBackendConfig
19
+
20
+ from .libs import (
21
+ format_for_mongo,
22
+ get_collection_name_for_model,
23
+ to_mongo,
24
+ )
25
+
26
+
27
+ class MongoBackend:
28
+ """MongoDB backend implementation."""
29
+
30
+ def __init__(self, context, config: MongoBackendConfig):
31
+ self.__context = context
32
+ self.__config = config
33
+ self.__client: Any = None
34
+
35
+ @staticmethod
36
+ def create_unique_connection_string(config: MongoBackendConfig) -> str:
37
+ """Create a unique connection string from config."""
38
+ host = config.host
39
+ port = getattr(config, "port", None)
40
+ username = getattr(config, "username", None)
41
+ password = getattr(config, "password", None)
42
+ database = getattr(config, "database", None)
43
+
44
+ connection_string = "mongodb://"
45
+ if username:
46
+ connection_string += f"{username}:{password}@"
47
+ connection_string += f"{host}"
48
+ if port:
49
+ connection_string += f":{port}"
50
+ if database:
51
+ connection_string += f"/{database}"
52
+ return connection_string
53
+
54
+ def __connect(self) -> None:
55
+ """Connect to MongoDB (private method)."""
56
+ from pymongo import MongoClient # noqa: PLC0415
57
+
58
+ connection_string = self.create_unique_connection_string(self.__config)
59
+ self.__client = MongoClient(connection_string)
60
+
61
+ def __get_database(self):
62
+ default_database_name = f"{self.__context.config.system_name}-{self.__context.config.environment}".lower()
63
+ database_name = getattr(self.__config, "database", default_database_name)
64
+ return self.__client[database_name]
65
+
66
+ def __disconnect(self) -> None:
67
+ """Disconnect from MongoDB (private method)."""
68
+ if self.__client:
69
+ self.__client.close()
70
+ self.__client = None
71
+
72
+ def __ensure_connected(self) -> None:
73
+ """Ensure MongoDB connection is established."""
74
+ if self.__client is None:
75
+ self.__connect()
76
+
77
+ def get_raw_client(self) -> Any:
78
+ self.__ensure_connected()
79
+ return self.__get_database()
80
+
81
+ def get_backend_name(self) -> str:
82
+ return "mongodb"
83
+
84
+ def create(self, model: InLayersModel, data: Mapping) -> Mapping:
85
+ """Create a new document in MongoDB."""
86
+ self.__ensure_connected()
87
+
88
+ collection_name = get_collection_name_for_model(model.get_model_definition())
89
+ database = self.__get_database()
90
+ collection = database[collection_name]
91
+ payload = dict(data)
92
+ formatted = format_for_mongo(payload)
93
+
94
+ pk_name = model.get_primary_key_name()
95
+ pk_value = formatted.get(pk_name)
96
+ if pk_value is None:
97
+ # Generate a simple ID - in production you might want UUID
98
+
99
+ pk_value = str(uuid4())
100
+ formatted[pk_name] = pk_value
101
+
102
+ # Use _id as MongoDB's primary key, mapping from model's primary key
103
+ insert_data = {**formatted, "_id": pk_value}
104
+ collection.insert_one(insert_data)
105
+ # Return without _id
106
+ result = {k: v for k, v in insert_data.items() if k != "_id"}
107
+ return result
108
+
109
+ def retrieve(self, model: InLayersModel, id: PrimaryKeyType) -> Mapping | None:
110
+ """Retrieve a document by ID."""
111
+ self.__ensure_connected()
112
+
113
+ collection_name = get_collection_name_for_model(model.get_model_definition())
114
+ database = self.__get_database()
115
+ collection = database[collection_name]
116
+ doc = collection.find_one({"_id": id})
117
+ if not doc:
118
+ return None
119
+ # Remove _id and return
120
+ result = {k: v for k, v in doc.items() if k != "_id"}
121
+ return result
122
+
123
+ def update(
124
+ self, model: InLayersModel, id: PrimaryKeyType, data: Mapping
125
+ ) -> Mapping:
126
+ """Update a document by ID.
127
+
128
+ Supports partial updates - only the fields provided in data will be updated.
129
+ Returns the full merged object (original + updates).
130
+ """
131
+ self.__ensure_connected()
132
+
133
+ collection_name = get_collection_name_for_model(model.get_model_definition())
134
+ database = self.__get_database()
135
+ collection = database[collection_name]
136
+
137
+ # Check if document exists
138
+ existing = collection.find_one({"_id": id})
139
+ if not existing:
140
+ raise KeyError(f"Instance with id {id!r} not found")
141
+
142
+ payload = dict(data)
143
+ formatted = format_for_mongo(payload)
144
+
145
+ # Ensure primary key field remains consistent
146
+ pk_name = model.get_primary_key_name()
147
+ formatted[pk_name] = id
148
+
149
+ # Update with _id mapping - use $set for partial update
150
+ update_data = {**formatted, "_id": id}
151
+ collection.update_one({"_id": id}, {"$set": update_data})
152
+
153
+ # Retrieve the updated document to return the full merged object
154
+ updated = collection.find_one({"_id": id})
155
+ if not updated:
156
+ raise KeyError(f"Instance with id {id!r} not found after update")
157
+
158
+ # Return without _id
159
+ result = {k: v for k, v in updated.items() if k != "_id"}
160
+ return result
161
+
162
+ def delete(self, model: InLayersModel, id: PrimaryKeyType) -> None:
163
+ """Delete a document by ID."""
164
+ self.__ensure_connected()
165
+
166
+ collection_name = get_collection_name_for_model(model.get_model_definition())
167
+ database = self.__get_database()
168
+ collection = database[collection_name]
169
+ collection.delete_one({"_id": id})
170
+
171
+ def search(self, model: InLayersModel, query: ModelSearch) -> ModelSearchResult:
172
+ """Search for documents matching the query."""
173
+ self.__ensure_connected()
174
+
175
+ collection_name = get_collection_name_for_model(
176
+ self.__context.config.environment, model.get_model_definition()
177
+ )
178
+ database = self.__get_database()
179
+ collection = database[collection_name]
180
+
181
+ # Build aggregation pipeline
182
+ pipeline = []
183
+
184
+ # Add match stage if there's a query
185
+ if query.query:
186
+ mongo_query = to_mongo(query.query)
187
+ pipeline.extend(mongo_query)
188
+ else:
189
+ pipeline.append({"$match": {}})
190
+
191
+ # Add sort stage if needed
192
+ if query.sort:
193
+ sort_direction = 1 if query.sort.order == SortOrder.asc else -1
194
+ pipeline.append({"$sort": {query.sort.key: sort_direction}})
195
+
196
+ # Add limit stage if needed
197
+ if query.take:
198
+ pipeline.append({"$limit": query.take})
199
+
200
+ # Execute aggregation
201
+ results = list(collection.aggregate(pipeline))
202
+ instances = [{k: v for k, v in doc.items() if k != "_id"} for doc in results]
203
+
204
+ return Box(instances=instances, page=query.page)
205
+
206
+ def bulk_insert(self, model: InLayersModel, data: list[Mapping]) -> None:
207
+ """Bulk insert documents."""
208
+ from pymongo.operations import UpdateOne # noqa: PLC0415
209
+
210
+ self.__ensure_connected()
211
+
212
+ collection_name = get_collection_name_for_model(model.get_model_definition())
213
+ database = self.__get_database()
214
+ collection = database[collection_name]
215
+ pk_name = model.get_primary_key_name()
216
+
217
+ # Prepare bulk write operations
218
+ operations = []
219
+ for item in data:
220
+ payload = dict(item)
221
+ formatted = format_for_mongo(payload)
222
+
223
+ pk_value = formatted.get(pk_name)
224
+ if pk_value is None:
225
+ pk_value = str(uuid4())
226
+ formatted[pk_name] = pk_value
227
+
228
+ doc = {**formatted, "_id": pk_value}
229
+ operations.append(
230
+ UpdateOne(
231
+ {"_id": pk_value},
232
+ {"$set": doc},
233
+ upsert=True,
234
+ )
235
+ )
236
+
237
+ if operations:
238
+ collection.bulk_write(operations)
239
+
240
+ def bulk_delete(self, model: InLayersModel, ids: list[PrimaryKeyType]) -> None:
241
+ """Bulk delete documents by IDs."""
242
+ self.__ensure_connected()
243
+
244
+ collection_name = get_collection_name_for_model(model.get_model_definition())
245
+ database = self.__get_database()
246
+ collection = database[collection_name]
247
+ collection.delete_many({"_id": {"$in": ids}})
248
+
249
+ def dispose(self) -> None:
250
+ """Clean up resources."""
251
+ self.__disconnect()
@@ -0,0 +1,43 @@
1
+ from collections.abc import Mapping
2
+ from enum import Enum
3
+ from typing import Any, Literal, Protocol
4
+
5
+
6
+ class SupportedBackend(Enum):
7
+ MongoDB = "mongodb"
8
+ DynamoDB = "dynamodb"
9
+
10
+
11
+ class MongoBackendConfig(Protocol):
12
+ type: Literal[SupportedBackend.MongoDB]
13
+ host: str
14
+ port: int | None
15
+ username: str | None
16
+ password: str | None
17
+ database: str | None
18
+
19
+
20
+ class DynamoDBBackendConfig(Protocol):
21
+ type: Literal[SupportedBackend.DynamoDB]
22
+ region: str | None
23
+ endpoint_url: str | None
24
+ aws_access_key_id: str | None
25
+ aws_secret_access_key: str | None
26
+ boto3: Any | None
27
+
28
+
29
+ BackendConfig = MongoBackendConfig | DynamoDBBackendConfig
30
+
31
+
32
+ class DataNamespace(Enum):
33
+ root = "in_layers_data"
34
+ backends = "in_layers_data_backends"
35
+
36
+
37
+ class InLayersDataConfig(Protocol):
38
+ default: BackendConfig
39
+ model_to_backend: Mapping[str, BackendConfig] | None
40
+
41
+
42
+ class WithInLayersDataConfig(Protocol):
43
+ config: InLayersDataConfig
@@ -0,0 +1,91 @@
1
+ from typing import Protocol
2
+
3
+ from in_layers.core import create_error_object
4
+ from in_layers.core.models.protocols import BackendProtocol, ModelDefinition
5
+
6
+ from .backends.dynamodb.services import DynamoDBBackend
7
+ from .backends.mongodb.services import MongoBackend
8
+ from .protocols import (
9
+ BackendConfig,
10
+ SupportedBackend,
11
+ WithInLayersDataConfig,
12
+ )
13
+
14
+
15
+ class _ExpectedContext(Protocol):
16
+ config: WithInLayersDataConfig
17
+
18
+
19
+ class InLayersDataServices:
20
+ def __init__(self, context: _ExpectedContext):
21
+ self.__context = context
22
+ self.__backend_by_unique_key = {}
23
+ self.__backends = None
24
+
25
+ def __initialize_backend(self, config: BackendConfig) -> BackendProtocol:
26
+ if config.type in [SupportedBackend.MongoDB, SupportedBackend.MongoDB.value]:
27
+ unique = MongoBackend.create_unique_connection_string(config)
28
+ if unique in self.__backend_by_unique_key:
29
+ return self.__backend_by_unique_key[unique]
30
+ backend = MongoBackend(self.__context, config)
31
+ self.__backend_by_unique_key[unique] = backend
32
+ return backend
33
+ elif config.type in [
34
+ SupportedBackend.DynamoDB,
35
+ SupportedBackend.DynamoDB.value,
36
+ ]:
37
+ unique = DynamoDBBackend.create_unique_connection_string(config)
38
+ if unique in self.__backend_by_unique_key:
39
+ return self.__backend_by_unique_key[unique]
40
+ backend = DynamoDBBackend(self.__context, config)
41
+ self.__backend_by_unique_key[unique] = backend
42
+ return backend
43
+ else:
44
+ raise ValueError(f"Unsupported backend type: {config.type}")
45
+
46
+ def __initialize_backends(self):
47
+ if self.__backends is not None:
48
+ return
49
+ config = self.__context.config.in_layers_data
50
+ default_backend_config = config.default
51
+ model_to_backend_config = getattr(config, "model_to_backend", {}) or {}
52
+
53
+ self.__backends = {"default": self.__initialize_backend(default_backend_config)}
54
+ for model_key, backend_config in model_to_backend_config.items():
55
+ backend_instance = self.__initialize_backend(backend_config)
56
+ self.__backends[model_key] = backend_instance
57
+
58
+ def __get_backend_for_model(self, meta: ModelDefinition) -> BackendProtocol:
59
+ self.__initialize_backends()
60
+ model_key_full = f"{meta.domain}.{meta.plural_name}"
61
+ if model_key_full in self.__backends:
62
+ return self.__backends[model_key_full]
63
+ if meta.domain in self.__backends:
64
+ return self.__backends[meta.domain]
65
+ return self.__backends["default"]
66
+
67
+ def get_model_backend(self, model_definition: ModelDefinition) -> BackendProtocol:
68
+ backend = self.__get_backend_for_model(model_definition)
69
+ if not backend:
70
+ raise ValueError(
71
+ f"No backend found for model {model_definition.domain}.{model_definition.plural_name}"
72
+ )
73
+ return backend
74
+
75
+ def dispose(self):
76
+ for backend in self.__backends.values():
77
+ try:
78
+ backend.dispose()
79
+ except Exception as e:
80
+ error_obj = create_error_object(
81
+ "DATA_SERVICES_DISPOSE_ERROR", "Error disposing backend", e
82
+ )
83
+ log = self.__context.log.get_inner_logger("dispose")
84
+ log.warn("Error disposing backend", error_obj)
85
+
86
+ self.__backends = None
87
+ self.__backend_by_unique_key = {}
88
+
89
+
90
+ def create(context: _ExpectedContext) -> InLayersDataServices:
91
+ return InLayersDataServices(context)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: in-layers-data
3
+ Version: 0.4.20
4
+ Summary: A data layer for In Layers Core. (in-layers-core). Part of the Node in Layers ecosystem
5
+ License: GPLv3
6
+ Keywords: layers,python,in-layers,node,domains,data,models,orm
7
+ Author: Mike Cornwell
8
+ Author-email: mike@mikecornwell.com
9
+ Requires-Python: >=3.13,<4.0
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
15
+ Requires-Dist: in-layers-core (>=0.4.2,<0.5.0)
16
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
17
+ Requires-Dist: python-box (>=7,<9)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # In Layers Data
21
+ A data layer for the In Layers Core framework.
22
+
23
+ NOTE: There are no explicit dependencies on any database. To use a specific database you must install it in your own system. These databases are "imported in" as the databases are actually used at runtime.
24
+
25
+ ## How To Use
26
+ 1. Install in-layers-data
27
+ 1. Set `"in_layers_data"` to the `in_layers_core.models.model_backend` property
28
+ 1. Add `in_layers_data` configuration to your config
29
+ 1. Install database libraries to use. Example: `pymongo` or `boto3`
30
+
31
+ ### Configuration Example
32
+ ```python
33
+ # config_base.py
34
+ from box import Box
35
+ def get_base_config():
36
+ return Box(
37
+ ...,
38
+ in_layers_core=Box(
39
+ ...
40
+ models=Box(
41
+ model_backend="in_layers_data",
42
+ )
43
+ ),
44
+ in_layers_data=Box(
45
+ default=Box(
46
+ type="mongodb"
47
+ # Connection information here
48
+ ),
49
+ # Optional: Set "domain" or "domain.ModelPluralNames" to a specific database configuration.
50
+ # model_to_backend=Box(
51
+ # "domain.ModelPluralNames"=Box(
52
+ # type="mongodb",
53
+ # host="different-host"
54
+ # )
55
+ # )
56
+ )
57
+ )
58
+
59
+
60
+ ```
61
+
62
+ ## Key Features
63
+ - Drop in, Swappable Databases
64
+ - Multi-database support
65
+ - Low dependencies
66
+
67
+ ## Databases Supported
68
+ - Mongodb
69
+ - Dynamodb
70
+
71
+ ## Database Info
72
+ ### Mongo
73
+ Mongodb requires `pymongo`
74
+
75
+ ### Dynamodb
76
+ Dynamodb requires `boto3`
77
+
78
+ #### Important
79
+ Dynamodb is very poor at performing search queries. While this is implemented, it is not-recommended for use. Instead use the retrieve.
@@ -0,0 +1,14 @@
1
+ in_layers/__init__.py,sha256=VyrcAibWxTcBorfOipl5a1Vg9ZY8hOQt2T-lsWOGFps,173
2
+ in_layers/data/__init__.py,sha256=eDl_a3x2QVMUauUSuTw0if180sdSER6DHYAip546d5M,387
3
+ in_layers/data/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ in_layers/data/backends/dynamodb/__init__.py,sha256=q5OQ4ygMLYHGV_L30K4HSrVk10dROgNTrjUg32Ki2XM,114
5
+ in_layers/data/backends/dynamodb/libs.py,sha256=wqzipG8FkMLmlt05jNmRwxvK6ycTVcfv3Wjra_r_sfU,3574
6
+ in_layers/data/backends/dynamodb/services.py,sha256=d6UEX-xuh2sWJwqjxdZxvTiJUJ1NG_nLdg3DZU8GCe4,13838
7
+ in_layers/data/backends/mongodb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ in_layers/data/backends/mongodb/libs.py,sha256=HC0iSd2TibdXaQmXdO5YJP0Q4ItAMJdTaJ_w9c9GZmE,9001
9
+ in_layers/data/backends/mongodb/services.py,sha256=pxF4P82ayaTGsnYoyqOHf3OYGKMdTHu8lMdQNmfUn8I,8607
10
+ in_layers/data/protocols.py,sha256=yIH2Bu6K11cTBws-IrnopKB4-L_ltf6yLWDZAWec844,960
11
+ in_layers/data/services.py,sha256=qPJCt4KzL7qfXWzXNzi8pnoeD7QSoZY-NpCB6KGKi74,3616
12
+ in_layers_data-0.4.20.dist-info/METADATA,sha256=GhGC-KpdSWZiDG5vdDFoONHRDJJjtxLcCEiJ5ywnxQQ,2367
13
+ in_layers_data-0.4.20.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
14
+ in_layers_data-0.4.20.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any