gault 0.1.0__tar.gz

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.
gault-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,298 @@
1
+ Metadata-Version: 2.3
2
+ Name: gault
3
+ Version: 0.1.0
4
+ Summary: A lightweight Object Document Mapper for MongoDB
5
+ Keywords: mongodb,odm,document-mapper,orm,database
6
+ Author: Xavier Barbosa
7
+ Author-email: Xavier Barbosa <clint.northwood@gmail.com>
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Dist: annotated-types>=0.7.0
15
+ Requires-Dist: pymongo>=4.15.4
16
+ Requires-Python: >=3.12
17
+ Project-URL: Homepage, https://github.com/johnoone/gault
18
+ Project-URL: Issues, https://github.com/johnoone/gault/issues
19
+ Project-URL: Repository, https://github.com/johnoone/gault.git
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Strata
23
+
24
+ A lightweight Object Document Mapper (ODM) for MongoDB with Python type hints and state tracking.
25
+
26
+ ## Features
27
+
28
+ - Type-safe MongoDB documents with Python type hints
29
+ - Field aliasing for database column mapping
30
+ - Query operators with Pythonic syntax
31
+ - Async manager for CRUD operations
32
+ - Aggregation pipeline support
33
+ - Automatic state tracking and dirty field detection
34
+ - Persistence tracking
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install gault
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from gault import Schema, Model, Field, configure, AsyncManager
46
+
47
+ # Schema: Persistent documents mapped to MongoDB collections
48
+ class Person(Schema, collection="people"):
49
+ id: Field[int] = configure(pk=True)
50
+ name: Field[str]
51
+ age: Field[int] = configure(db_alias="person_age")
52
+
53
+ # Model: Non-persistent data classes (projections, view models, etc.)
54
+ class PersonSummary(Model):
55
+ name: Field[str]
56
+ total: Field[int]
57
+
58
+ # Create manager
59
+ manager = AsyncManager(database)
60
+
61
+ # Query and modify
62
+ person = await manager.get(Person, filter=Person.id == 1)
63
+ person.age = 43
64
+ await manager.save(person, atomic=True) # Only updates dirty fields
65
+ ```
66
+
67
+ ## Schema vs Model
68
+
69
+ - **Schema**: Persistent MongoDB collections. Requires `collection` parameter and gets registered globally.
70
+ - **Model**: Non-persistent data structures for aggregation projections, view models, or intermediate transformations.
71
+
72
+ ## Field Configuration
73
+
74
+ Fields can be configured with metadata using the `configure()` function:
75
+
76
+ ```python
77
+ class Person(Schema, collection="people"):
78
+ # Primary key field - used for filtering in save() operations
79
+ id: Field[int] = configure(pk=True)
80
+
81
+ # Regular field
82
+ name: Field[str]
83
+
84
+ # Field with database alias (field name differs from DB column)
85
+ age: Field[int] = configure(db_alias="person_age")
86
+ ```
87
+
88
+ **Note**: Fields marked with `pk=True` are used as the filter criteria in `save()` operations to identify the document for upsert.
89
+
90
+ ## Querying with Filters
91
+
92
+ Strata provides multiple ways to filter documents using type-safe field expressions.
93
+
94
+ ### Operator Expressions
95
+
96
+ Use class fields with comparison operators to build type-safe queries:
97
+
98
+ ```python
99
+ # Comparison operators
100
+ Person.age == 42 # Equal
101
+ Person.age != 30 # Not equal
102
+ Person.age < 50 # Less than
103
+ Person.age <= 50 # Less than or equal
104
+ Person.age > 18 # Greater than
105
+ Person.age >= 18 # Greater than or equal
106
+ Person.id.in_([1, 2, 3]) # In list
107
+ Person.id.nin([4, 5]) # Not in list
108
+
109
+ # Logical operators
110
+ filter = (Person.age >= 18) & (Person.age < 65) # AND
111
+ filter = (Person.name == "Alice") | (Person.name == "Bob") # OR
112
+ filter = ~(Person.age < 18) # NOT
113
+
114
+ # Complex expressions
115
+ filter = (Person.age >= 18) & ((Person.name == "Alice") | (Person.name == "Bob"))
116
+ ```
117
+
118
+ ### Pipeline Filters
119
+
120
+ For advanced queries, use the `Pipeline` API with aggregation stages:
121
+
122
+ ```python
123
+ from gault import Pipeline
124
+
125
+ # Match and sort
126
+ pipeline = Pipeline().match(Person.age >= 18).sort(Person.age.asc())
127
+
128
+ # Pagination
129
+ pipeline = Pipeline().skip(10).take(20)
130
+
131
+ # Group and aggregate
132
+ from gault import Sum
133
+ pipeline = (
134
+ Pipeline()
135
+ .match(Person.age >= 18)
136
+ .group(by=Person.name, accumulators={"total": Sum(Person.age)})
137
+ )
138
+
139
+ # Multiple stages
140
+ pipeline = (
141
+ Pipeline()
142
+ .match(Person.age >= 18)
143
+ .sort(Person.age.desc())
144
+ .take(10)
145
+ )
146
+ ```
147
+
148
+ ### Raw MongoDB Queries
149
+
150
+ You can also use raw MongoDB query dictionaries:
151
+
152
+ ```python
153
+ # Dict filter
154
+ filter = {"age": {"$gte": 18}}
155
+
156
+ # Raw pipeline stages
157
+ pipeline = [
158
+ {"$match": {"age": {"$gte": 18}}},
159
+ {"$sort": {"age": -1}},
160
+ {"$limit": 10}
161
+ ]
162
+ ```
163
+
164
+ ## AsyncManager Methods
165
+
166
+ ### `find(model, filter=None)`
167
+ Finds a single document matching the filter. Returns `None` if not found.
168
+
169
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
170
+
171
+ ```python
172
+ # With operator
173
+ person = await manager.find(Person, filter=Person.age == 42)
174
+
175
+ # With pipeline
176
+ pipeline = Pipeline().match(Person.age > 30).sort(Person.name.asc())
177
+ person = await manager.find(Person, filter=pipeline)
178
+
179
+ # With dict
180
+ person = await manager.find(Person, filter={"age": 42})
181
+ ```
182
+
183
+ ### `get(model, filter=None)`
184
+ Like `find()`, but raises `NotFound` exception if no document is found.
185
+
186
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
187
+
188
+ ```python
189
+ try:
190
+ person = await manager.get(Person, filter=Person.id == 123)
191
+ except NotFound:
192
+ print("Person not found")
193
+ ```
194
+
195
+ ### `select(model, filter=None, skip=None, take=None)`
196
+ Returns an async iterator of documents matching the filter. Supports pagination.
197
+
198
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
199
+
200
+ ```python
201
+ # Operator with in_()
202
+ async for person in manager.select(Person, filter=Person.id.in_([1, 2, 3])):
203
+ print(person.name)
204
+
205
+ # Pipeline
206
+ pipeline = Pipeline().match(Person.age >= 18).sort(Person.age.desc())
207
+ async for person in manager.select(Person, filter=pipeline, take=10):
208
+ print(person.name)
209
+
210
+ # Complex filter
211
+ filter = (Person.age >= 18) & (Person.age < 65)
212
+ async for person in manager.select(Person, filter=filter):
213
+ print(person.name)
214
+ ```
215
+
216
+ ### `insert(instance)`
217
+ Inserts a new document into the database. Only works with `Schema` instances.
218
+
219
+ ```python
220
+ new_person = Person(id=1, name="Alice", age=30)
221
+ await manager.insert(new_person)
222
+ ```
223
+
224
+ ### `save(instance, refresh=False, atomic=False)`
225
+ Upserts a document using `find_one_and_update`. Supports atomic updates with dirty field tracking.
226
+
227
+ - **`refresh=False`**: If `True`, refreshes the instance with the document returned from the database
228
+ - **`atomic=False`**: If `True` and the instance is already persisted, only updates dirty fields
229
+
230
+ ```python
231
+ # Create or update
232
+ person = Person(id=1, name="Bob", age=25)
233
+ await manager.save(person)
234
+
235
+ # Later, update only changed fields
236
+ person.age = 26
237
+ await manager.save(person, atomic=True) # Only updates 'person_age' field
238
+ ```
239
+
240
+ ## Persistence and Dirty Fields
241
+
242
+ Strata tracks the persistence state and modifications of your documents automatically.
243
+
244
+ ### Persistence Tracking
245
+
246
+ When documents are loaded from the database or saved, they are marked as persisted:
247
+
248
+ ```python
249
+ # Loaded from DB - automatically marked as persisted
250
+ person = await manager.find(Person, filter=Person.id == 1)
251
+ assert manager.persistence.is_persisted(person)
252
+
253
+ # Newly created - not yet persisted
254
+ new_person = Person(id=2, name="Charlie", age=35)
255
+ assert not manager.persistence.is_persisted(new_person)
256
+
257
+ # After saving - marked as persisted
258
+ await manager.save(new_person)
259
+ assert manager.persistence.is_persisted(new_person)
260
+ ```
261
+
262
+ ### Dirty Field Tracking
263
+
264
+ Strata snapshots document state and tracks which fields have been modified:
265
+
266
+ ```python
267
+ person = await manager.get(Person, filter=Person.id == 1)
268
+
269
+ # Modify some fields
270
+ person.name = "New Name"
271
+ person.age = 50
272
+
273
+ # Check which fields changed
274
+ dirty_fields = manager.state_tracker.get_dirty_fields(person)
275
+ # dirty_fields == {'name', 'age'}
276
+
277
+ # Atomic save only updates changed fields
278
+ await manager.save(person, atomic=True)
279
+ ```
280
+
281
+ ### Atomic Updates
282
+
283
+ When using `atomic=True`, the `save()` method generates optimal MongoDB updates:
284
+
285
+ - **Dirty fields**: Updated with `$set`
286
+ - **Unchanged fields**: Set with `$setOnInsert` (only on insert, not update)
287
+ - **Primary key fields**: Used in the filter
288
+
289
+ This minimizes race conditions and reduces unnecessary updates.
290
+
291
+ ## Requirements
292
+
293
+ - Python >= 3.12
294
+ - PyMongo >= 4.15.4
295
+
296
+ ## License
297
+
298
+ MIT
gault-0.1.0/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # Strata
2
+
3
+ A lightweight Object Document Mapper (ODM) for MongoDB with Python type hints and state tracking.
4
+
5
+ ## Features
6
+
7
+ - Type-safe MongoDB documents with Python type hints
8
+ - Field aliasing for database column mapping
9
+ - Query operators with Pythonic syntax
10
+ - Async manager for CRUD operations
11
+ - Aggregation pipeline support
12
+ - Automatic state tracking and dirty field detection
13
+ - Persistence tracking
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install gault
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ from gault import Schema, Model, Field, configure, AsyncManager
25
+
26
+ # Schema: Persistent documents mapped to MongoDB collections
27
+ class Person(Schema, collection="people"):
28
+ id: Field[int] = configure(pk=True)
29
+ name: Field[str]
30
+ age: Field[int] = configure(db_alias="person_age")
31
+
32
+ # Model: Non-persistent data classes (projections, view models, etc.)
33
+ class PersonSummary(Model):
34
+ name: Field[str]
35
+ total: Field[int]
36
+
37
+ # Create manager
38
+ manager = AsyncManager(database)
39
+
40
+ # Query and modify
41
+ person = await manager.get(Person, filter=Person.id == 1)
42
+ person.age = 43
43
+ await manager.save(person, atomic=True) # Only updates dirty fields
44
+ ```
45
+
46
+ ## Schema vs Model
47
+
48
+ - **Schema**: Persistent MongoDB collections. Requires `collection` parameter and gets registered globally.
49
+ - **Model**: Non-persistent data structures for aggregation projections, view models, or intermediate transformations.
50
+
51
+ ## Field Configuration
52
+
53
+ Fields can be configured with metadata using the `configure()` function:
54
+
55
+ ```python
56
+ class Person(Schema, collection="people"):
57
+ # Primary key field - used for filtering in save() operations
58
+ id: Field[int] = configure(pk=True)
59
+
60
+ # Regular field
61
+ name: Field[str]
62
+
63
+ # Field with database alias (field name differs from DB column)
64
+ age: Field[int] = configure(db_alias="person_age")
65
+ ```
66
+
67
+ **Note**: Fields marked with `pk=True` are used as the filter criteria in `save()` operations to identify the document for upsert.
68
+
69
+ ## Querying with Filters
70
+
71
+ Strata provides multiple ways to filter documents using type-safe field expressions.
72
+
73
+ ### Operator Expressions
74
+
75
+ Use class fields with comparison operators to build type-safe queries:
76
+
77
+ ```python
78
+ # Comparison operators
79
+ Person.age == 42 # Equal
80
+ Person.age != 30 # Not equal
81
+ Person.age < 50 # Less than
82
+ Person.age <= 50 # Less than or equal
83
+ Person.age > 18 # Greater than
84
+ Person.age >= 18 # Greater than or equal
85
+ Person.id.in_([1, 2, 3]) # In list
86
+ Person.id.nin([4, 5]) # Not in list
87
+
88
+ # Logical operators
89
+ filter = (Person.age >= 18) & (Person.age < 65) # AND
90
+ filter = (Person.name == "Alice") | (Person.name == "Bob") # OR
91
+ filter = ~(Person.age < 18) # NOT
92
+
93
+ # Complex expressions
94
+ filter = (Person.age >= 18) & ((Person.name == "Alice") | (Person.name == "Bob"))
95
+ ```
96
+
97
+ ### Pipeline Filters
98
+
99
+ For advanced queries, use the `Pipeline` API with aggregation stages:
100
+
101
+ ```python
102
+ from gault import Pipeline
103
+
104
+ # Match and sort
105
+ pipeline = Pipeline().match(Person.age >= 18).sort(Person.age.asc())
106
+
107
+ # Pagination
108
+ pipeline = Pipeline().skip(10).take(20)
109
+
110
+ # Group and aggregate
111
+ from gault import Sum
112
+ pipeline = (
113
+ Pipeline()
114
+ .match(Person.age >= 18)
115
+ .group(by=Person.name, accumulators={"total": Sum(Person.age)})
116
+ )
117
+
118
+ # Multiple stages
119
+ pipeline = (
120
+ Pipeline()
121
+ .match(Person.age >= 18)
122
+ .sort(Person.age.desc())
123
+ .take(10)
124
+ )
125
+ ```
126
+
127
+ ### Raw MongoDB Queries
128
+
129
+ You can also use raw MongoDB query dictionaries:
130
+
131
+ ```python
132
+ # Dict filter
133
+ filter = {"age": {"$gte": 18}}
134
+
135
+ # Raw pipeline stages
136
+ pipeline = [
137
+ {"$match": {"age": {"$gte": 18}}},
138
+ {"$sort": {"age": -1}},
139
+ {"$limit": 10}
140
+ ]
141
+ ```
142
+
143
+ ## AsyncManager Methods
144
+
145
+ ### `find(model, filter=None)`
146
+ Finds a single document matching the filter. Returns `None` if not found.
147
+
148
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
149
+
150
+ ```python
151
+ # With operator
152
+ person = await manager.find(Person, filter=Person.age == 42)
153
+
154
+ # With pipeline
155
+ pipeline = Pipeline().match(Person.age > 30).sort(Person.name.asc())
156
+ person = await manager.find(Person, filter=pipeline)
157
+
158
+ # With dict
159
+ person = await manager.find(Person, filter={"age": 42})
160
+ ```
161
+
162
+ ### `get(model, filter=None)`
163
+ Like `find()`, but raises `NotFound` exception if no document is found.
164
+
165
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
166
+
167
+ ```python
168
+ try:
169
+ person = await manager.get(Person, filter=Person.id == 123)
170
+ except NotFound:
171
+ print("Person not found")
172
+ ```
173
+
174
+ ### `select(model, filter=None, skip=None, take=None)`
175
+ Returns an async iterator of documents matching the filter. Supports pagination.
176
+
177
+ **Filter types**: Operator expression, Pipeline, dict, or list of stages.
178
+
179
+ ```python
180
+ # Operator with in_()
181
+ async for person in manager.select(Person, filter=Person.id.in_([1, 2, 3])):
182
+ print(person.name)
183
+
184
+ # Pipeline
185
+ pipeline = Pipeline().match(Person.age >= 18).sort(Person.age.desc())
186
+ async for person in manager.select(Person, filter=pipeline, take=10):
187
+ print(person.name)
188
+
189
+ # Complex filter
190
+ filter = (Person.age >= 18) & (Person.age < 65)
191
+ async for person in manager.select(Person, filter=filter):
192
+ print(person.name)
193
+ ```
194
+
195
+ ### `insert(instance)`
196
+ Inserts a new document into the database. Only works with `Schema` instances.
197
+
198
+ ```python
199
+ new_person = Person(id=1, name="Alice", age=30)
200
+ await manager.insert(new_person)
201
+ ```
202
+
203
+ ### `save(instance, refresh=False, atomic=False)`
204
+ Upserts a document using `find_one_and_update`. Supports atomic updates with dirty field tracking.
205
+
206
+ - **`refresh=False`**: If `True`, refreshes the instance with the document returned from the database
207
+ - **`atomic=False`**: If `True` and the instance is already persisted, only updates dirty fields
208
+
209
+ ```python
210
+ # Create or update
211
+ person = Person(id=1, name="Bob", age=25)
212
+ await manager.save(person)
213
+
214
+ # Later, update only changed fields
215
+ person.age = 26
216
+ await manager.save(person, atomic=True) # Only updates 'person_age' field
217
+ ```
218
+
219
+ ## Persistence and Dirty Fields
220
+
221
+ Strata tracks the persistence state and modifications of your documents automatically.
222
+
223
+ ### Persistence Tracking
224
+
225
+ When documents are loaded from the database or saved, they are marked as persisted:
226
+
227
+ ```python
228
+ # Loaded from DB - automatically marked as persisted
229
+ person = await manager.find(Person, filter=Person.id == 1)
230
+ assert manager.persistence.is_persisted(person)
231
+
232
+ # Newly created - not yet persisted
233
+ new_person = Person(id=2, name="Charlie", age=35)
234
+ assert not manager.persistence.is_persisted(new_person)
235
+
236
+ # After saving - marked as persisted
237
+ await manager.save(new_person)
238
+ assert manager.persistence.is_persisted(new_person)
239
+ ```
240
+
241
+ ### Dirty Field Tracking
242
+
243
+ Strata snapshots document state and tracks which fields have been modified:
244
+
245
+ ```python
246
+ person = await manager.get(Person, filter=Person.id == 1)
247
+
248
+ # Modify some fields
249
+ person.name = "New Name"
250
+ person.age = 50
251
+
252
+ # Check which fields changed
253
+ dirty_fields = manager.state_tracker.get_dirty_fields(person)
254
+ # dirty_fields == {'name', 'age'}
255
+
256
+ # Atomic save only updates changed fields
257
+ await manager.save(person, atomic=True)
258
+ ```
259
+
260
+ ### Atomic Updates
261
+
262
+ When using `atomic=True`, the `save()` method generates optimal MongoDB updates:
263
+
264
+ - **Dirty fields**: Updated with `$set`
265
+ - **Unchanged fields**: Set with `$setOnInsert` (only on insert, not update)
266
+ - **Primary key fields**: Used in the filter
267
+
268
+ This minimizes race conditions and reduces unnecessary updates.
269
+
270
+ ## Requirements
271
+
272
+ - Python >= 3.12
273
+ - PyMongo >= 4.15.4
274
+
275
+ ## License
276
+
277
+ MIT
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "gault"
3
+ version = "0.1.0"
4
+ description = "A lightweight Object Document Mapper for MongoDB"
5
+ readme = "README.md"
6
+ authors = [{ name = "Xavier Barbosa", email = "clint.northwood@gmail.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["annotated-types>=0.7.0", "pymongo>=4.15.4"]
9
+ keywords = ["mongodb", "odm", "document-mapper", "orm", "database"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Database",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ ]
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.11,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "anyio>=4.11.0",
25
+ "mypy>=1.18.2",
26
+ "pytest>=9.0.1",
27
+ "pytest-cov>=7.0.0",
28
+ "pytest-subtests>=0.15.0",
29
+ "ruff>=0.14.6",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/johnoone/gault"
34
+ Repository = "https://github.com/johnoone/gault.git"
35
+ Issues = "https://github.com/johnoone/gault/issues"
36
+
37
+ [tool.pytest.ini_options]
38
+ anyio_mode = "auto"
39
+
40
+ [tool.ruff]
41
+ target-version = "py312"
42
+
43
+ [tool.ruff.lint]
44
+ pylint.max-args = 8
45
+ extend-select = ["ALL"]
46
+ extend-ignore = [
47
+ "A001",
48
+ "A002",
49
+ "ARG001",
50
+ "ARG002",
51
+ "ANN401",
52
+ "COM812",
53
+ "D100",
54
+ "D101",
55
+ "D102",
56
+ "D103",
57
+ "D104",
58
+ "D105",
59
+ "D107",
60
+ "D203",
61
+ "D213",
62
+ "E501",
63
+ "F402",
64
+ "FBT003",
65
+ "N818",
66
+ "PLR2004",
67
+ "SIM108",
68
+ ]
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from .accumulators import Accumulator as Accumulator
4
+ from .accumulators import Sum as Sum
5
+ from .exceptions import Forbidden as Forbidden
6
+ from .exceptions import NotFound as NotFound
7
+ from .exceptions import Unprocessable as Unprocessable
8
+ from .expressions import Var as Var
9
+ from .managers import AsyncManager as AsyncManager
10
+ from .managers import Manager as Manager
11
+ from .managers import Persistence as Persistence
12
+ from .managers import StateTracker as StateTracker
13
+ from .mappers import Mapper as Mapper
14
+ from .mappers import get_mapper as get_mapper
15
+ from .models import Attribute as Attribute
16
+ from .models import Model as Model
17
+ from .models import Schema as Schema
18
+ from .models import configure as configure
19
+ from .models import get_collection as get_collection
20
+ from .models import get_schema as get_schema
21
+ from .pipelines import Pipeline as Pipeline
22
+ from .predicates import Field as Field