registers 2.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.
- registers-2.1.0/LICENSE +6 -0
- registers-2.1.0/PKG-INFO +646 -0
- registers-2.1.0/README.md +621 -0
- registers-2.1.0/pyproject.toml +53 -0
- registers-2.1.0/setup.cfg +4 -0
- registers-2.1.0/src/registers/__init__.py +2 -0
- registers-2.1.0/src/registers/cli/__init__.py +53 -0
- registers-2.1.0/src/registers/cli/container.py +66 -0
- registers-2.1.0/src/registers/cli/dispatcher.py +106 -0
- registers-2.1.0/src/registers/cli/exceptions.py +44 -0
- registers-2.1.0/src/registers/cli/middleware.py +83 -0
- registers-2.1.0/src/registers/cli/parser.py +176 -0
- registers-2.1.0/src/registers/cli/plugins.py +75 -0
- registers-2.1.0/src/registers/cli/registry.py +246 -0
- registers-2.1.0/src/registers/cli/utils/reflection.py +67 -0
- registers-2.1.0/src/registers/cli/utils/typing.py +64 -0
- registers-2.1.0/src/registers/db/__init__.py +93 -0
- registers-2.1.0/src/registers/db/decorators.py +297 -0
- registers-2.1.0/src/registers/db/engine.py +94 -0
- registers-2.1.0/src/registers/db/exceptions.py +68 -0
- registers-2.1.0/src/registers/db/fields.py +21 -0
- registers-2.1.0/src/registers/db/metadata.py +99 -0
- registers-2.1.0/src/registers/db/registry.py +626 -0
- registers-2.1.0/src/registers/db/relations.py +299 -0
- registers-2.1.0/src/registers/db/schema.py +191 -0
- registers-2.1.0/src/registers/db/security.py +61 -0
- registers-2.1.0/src/registers/db/typing_utils.py +138 -0
- registers-2.1.0/src/registers.egg-info/PKG-INFO +646 -0
- registers-2.1.0/src/registers.egg-info/SOURCES.txt +35 -0
- registers-2.1.0/src/registers.egg-info/dependency_links.txt +1 -0
- registers-2.1.0/src/registers.egg-info/requires.txt +7 -0
- registers-2.1.0/src/registers.egg-info/top_level.txt +1 -0
- registers-2.1.0/tests/test_cli_registry.py +321 -0
- registers-2.1.0/tests/test_cli_registry_edge_cases.py +396 -0
- registers-2.1.0/tests/test_db_registry.py +963 -0
- registers-2.1.0/tests/test_db_registry_edge_cases.py +280 -0
- registers-2.1.0/tests/test_db_registry_fastapi_integration.py +223 -0
registers-2.1.0/LICENSE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
## LICENSE
|
|
2
|
+
Copyright (c) [2026] [Charles DeFreese]
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
5
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
6
|
+
|
registers-2.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: registers
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Decorator-driven persistence registry for Pydantic models and CLI tooling
|
|
5
|
+
Author: Charles DeFreese
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nexustech101/registers
|
|
8
|
+
Project-URL: Repository, https://github.com/nexustech101/registers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pydantic>=2.0
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
<div align="center">
|
|
28
|
+
|
|
29
|
+
# Registers Python Framework
|
|
30
|
+
|
|
31
|
+
**Decorator-driven CLI tooling and database persistence for Python.**
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/registers/)
|
|
34
|
+
[](https://pypi.org/project/registers/)
|
|
35
|
+
[](https://github.com/nexustech101/registers/actions)
|
|
36
|
+
[](https://github.com/nexustech101/registers/blob/main/LICENSE)
|
|
37
|
+
|
|
38
|
+
[CLI Framework](#registerscli) · [Database Registry](#registersdb) · [FastAPI Integration](#fastapi-integration) · [Error Reference](#error-reference)
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
<br>
|
|
42
|
+
|
|
43
|
+
A Python framework built with **Developer Experience (DX)** in mind. `registers` uses a clean, ergonomic decorator registry design pattern to eliminate boilerplate when building CLI tools and database-backed applications — from lightweight scripts and data engineering pipelines to full ecommerce systems and enterprise-scale relational models.
|
|
44
|
+
|
|
45
|
+
Designed to integrate seamlessly with **FastAPI** and other ASGI/WSGI frameworks out of the box.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install registers
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Contents
|
|
54
|
+
|
|
55
|
+
- [Why registers?](#why-registers)
|
|
56
|
+
- [Packages](#packages)
|
|
57
|
+
- [registers.cli — CLI Framework](#registerscli)
|
|
58
|
+
- [Quick Start](#cli-quick-start)
|
|
59
|
+
- [Argument Types](#argument-types)
|
|
60
|
+
- [Command Aliases](#command-aliases)
|
|
61
|
+
- [Dependency Injection](#dependency-injection)
|
|
62
|
+
- [Middleware](#middleware)
|
|
63
|
+
- [Plugin System](#plugin-system)
|
|
64
|
+
- [Error Handling](#error-handling)
|
|
65
|
+
- [registers.db — Database Registry](#registersdb)
|
|
66
|
+
- [Quick Start](#db-quick-start)
|
|
67
|
+
- [CRUD API](#crud-api)
|
|
68
|
+
- [Querying](#querying)
|
|
69
|
+
- [Schema Management](#schema-management)
|
|
70
|
+
- [Relationships](#relationships)
|
|
71
|
+
- [FastAPI Integration](#fastapi-integration)
|
|
72
|
+
- [Installation](#installation)
|
|
73
|
+
- [Requirements](#requirements)
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Why registers?
|
|
78
|
+
|
|
79
|
+
Most Python projects involve some combination of two recurring problems: **wiring up CLI commands** and **persisting data models**. The standard solutions — `argparse`, raw SQLAlchemy, `click` — are powerful but verbose. You spend more time writing plumbing than writing logic.
|
|
80
|
+
|
|
81
|
+
`registers` solves both with a consistent design philosophy: **register once, use everywhere**. A single decorator on a function makes it a CLI command. A single decorator on a Pydantic model gives it a full persistence layer. The framework handles the wiring; you write the behaviour.
|
|
82
|
+
|
|
83
|
+
**It is particularly well-suited for:**
|
|
84
|
+
|
|
85
|
+
- **Data engineering and modeling** — define typed, validated models with automatic table creation and schema evolution
|
|
86
|
+
- **Ecommerce and multi-entity systems** — first-class relationship descriptors for `HasMany`, `BelongsTo`, and `HasManyThrough`
|
|
87
|
+
- **Enterprise relational schemas** — transaction support, upsert semantics, and unique constraint management
|
|
88
|
+
- **FastAPI services** — models attach `create_schema` / `drop_schema` / `schema_exists` class methods that slot directly into FastAPI's `lifespan` startup hooks
|
|
89
|
+
- **Rapid CLI tooling** — go from a plain Python function to a fully-parsed, aliased, DI-wired CLI command in one decorator
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Packages
|
|
94
|
+
|
|
95
|
+
| Package | Purpose |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `registers.cli` | Decorator-based CLI framework with argparse, DI, middleware, and plugin loading |
|
|
98
|
+
| `registers.db` | SQLAlchemy-backed persistence manager for Pydantic models |
|
|
99
|
+
|
|
100
|
+
Both packages are independent. Use one, the other, or both together.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## registers.cli
|
|
105
|
+
|
|
106
|
+
A lightweight, decorator-driven CLI framework. Register Python functions as subcommands — argument parsing, type coercion, alias resolution, dependency injection, and middleware hooks are all handled by the framework.
|
|
107
|
+
|
|
108
|
+
### CLI Quick Start
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from registers.cli import CommandRegistry
|
|
112
|
+
|
|
113
|
+
cli = CommandRegistry()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cli.register(
|
|
117
|
+
ops=["-g", "--greet"],
|
|
118
|
+
name="greet",
|
|
119
|
+
description="Greet someone by name",
|
|
120
|
+
)
|
|
121
|
+
def greet(name: str) -> str:
|
|
122
|
+
return f"Hello, {name}!"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
cli.run()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python app.py greet Alice
|
|
131
|
+
python app.py --greet Alice
|
|
132
|
+
python app.py g Alice
|
|
133
|
+
# → Hello, Alice!
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Argument Types
|
|
137
|
+
|
|
138
|
+
Argument behaviour is inferred directly from Python type annotations — no schema definitions, no `add_argument` calls.
|
|
139
|
+
|
|
140
|
+
| Annotation | CLI behaviour |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `str` | Required positional argument |
|
|
143
|
+
| `int` | Required positional integer (auto-coerced) |
|
|
144
|
+
| `float` | Required positional float (auto-coerced) |
|
|
145
|
+
| `bool` | Optional `--flag` (store_true) |
|
|
146
|
+
| `Optional[T]` | Optional `--arg value` |
|
|
147
|
+
| Defaulted parameter | Optional `--arg value` |
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
@cli.register(name="create-report", description="Generate a report")
|
|
151
|
+
def create_report(
|
|
152
|
+
title: str, # required positional
|
|
153
|
+
pages: int, # required positional, coerced to int
|
|
154
|
+
verbose: bool = False, # optional --verbose flag
|
|
155
|
+
output: Optional[str] = None, # optional --output path
|
|
156
|
+
) -> str:
|
|
157
|
+
...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python app.py create-report "Q3 Summary" 12 --verbose --output ./reports
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Command Aliases
|
|
165
|
+
|
|
166
|
+
The `ops` field registers shorthand and flag-style aliases alongside the canonical command name. All three forms are resolved automatically:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
@cli.register(
|
|
170
|
+
ops=["-s", "--sync"],
|
|
171
|
+
name="sync",
|
|
172
|
+
description="Sync the database",
|
|
173
|
+
)
|
|
174
|
+
def sync(target: str) -> None:
|
|
175
|
+
...
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
python app.py sync production
|
|
180
|
+
python app.py --sync production
|
|
181
|
+
python app.py s production
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Dependency Injection
|
|
185
|
+
|
|
186
|
+
Use `DIContainer` to bind service instances to types. Any command parameter whose type is registered in the container is injected automatically and hidden from the CLI — callers never need to pass it.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from registers.cli import CommandRegistry, DIContainer, Dispatcher, build_parser
|
|
190
|
+
|
|
191
|
+
registry = CommandRegistry()
|
|
192
|
+
container = DIContainer()
|
|
193
|
+
|
|
194
|
+
container.register(DatabaseService, DatabaseService(url="sqlite:///app.db"))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@registry.register(name="seed", description="Seed the database")
|
|
198
|
+
def seed(count: int, db: DatabaseService) -> str:
|
|
199
|
+
db.insert_fixtures(count)
|
|
200
|
+
return f"Seeded {count} records."
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
parser = build_parser(registry, container)
|
|
204
|
+
dispatcher = Dispatcher(registry, container)
|
|
205
|
+
args = parser.parse_args()
|
|
206
|
+
|
|
207
|
+
if args.command:
|
|
208
|
+
cli_args = {k: v for k, v in vars(args).items() if k != "command"}
|
|
209
|
+
dispatcher.dispatch(args.command, cli_args)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
python app.py seed 100 # `db` is injected; only `count` appears on the CLI
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Middleware
|
|
217
|
+
|
|
218
|
+
`MiddlewareChain` provides ordered pre- and post-execution hooks. Pre-hooks receive the command name and resolved kwargs; post-hooks receive the command name and return value.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from registers.cli import CommandRegistry, MiddlewareChain, logging_middleware_pre, logging_middleware_post
|
|
222
|
+
|
|
223
|
+
cli = CommandRegistry()
|
|
224
|
+
chain = MiddlewareChain()
|
|
225
|
+
|
|
226
|
+
chain.add_pre(logging_middleware_pre) # built-in: logs command + args, starts timer
|
|
227
|
+
chain.add_post(logging_middleware_post) # built-in: logs completion + elapsed time
|
|
228
|
+
|
|
229
|
+
# Custom hook
|
|
230
|
+
def audit_hook(command: str, result: Any) -> None:
|
|
231
|
+
audit_log.write(command, result)
|
|
232
|
+
|
|
233
|
+
chain.add_post(audit_hook)
|
|
234
|
+
|
|
235
|
+
cli.run(middleware=chain)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Plugin System
|
|
239
|
+
|
|
240
|
+
`load_plugins` dynamically imports every non-private module in a package. Any `@registry.register(...)` calls at module level execute on import — no manual wiring in `main.py` required.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from registers.cli import CommandRegistry, load_plugins
|
|
244
|
+
|
|
245
|
+
cli = CommandRegistry()
|
|
246
|
+
load_plugins("app.commands", cli) # auto-discovers app/commands/*.py
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
cli.run()
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
app/
|
|
254
|
+
commands/
|
|
255
|
+
users.py # @cli.register(name="create-user", ...)
|
|
256
|
+
reports.py # @cli.register(name="export", ...)
|
|
257
|
+
deploy.py # @cli.register(name="deploy", ...)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Error Handling
|
|
261
|
+
|
|
262
|
+
The framework does not impose an error handling policy. A clean pattern is to wrap command handlers with your own decorator:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
import functools, sys
|
|
266
|
+
from typing import Any, Callable
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def handle_errors(func: Callable) -> Callable:
|
|
270
|
+
@functools.wraps(func)
|
|
271
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
272
|
+
try:
|
|
273
|
+
return func(*args, **kwargs)
|
|
274
|
+
except KeyboardInterrupt:
|
|
275
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
276
|
+
sys.exit(0)
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
279
|
+
sys.exit(1)
|
|
280
|
+
return wrapper
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@cli.register(name="deploy", description="Deploy to an environment")
|
|
284
|
+
@handle_errors
|
|
285
|
+
def deploy(env: str) -> str:
|
|
286
|
+
...
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## registers.db
|
|
292
|
+
|
|
293
|
+
A SQLAlchemy-backed persistence manager for Pydantic models. One decorator gives your model a full CRUD interface, automatic table creation, schema evolution helpers, and opt-in relationship descriptors — with no separate repository classes, no manual session management, and no raw SQL.
|
|
294
|
+
|
|
295
|
+
### DB Quick Start
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from pydantic import BaseModel
|
|
299
|
+
from registers.db import database_registry
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@database_registry(
|
|
303
|
+
"app.db",
|
|
304
|
+
table_name="users",
|
|
305
|
+
key_field="id",
|
|
306
|
+
autoincrement=True,
|
|
307
|
+
unique_fields=["email"],
|
|
308
|
+
)
|
|
309
|
+
class User(BaseModel):
|
|
310
|
+
id: int | None = None
|
|
311
|
+
name: str
|
|
312
|
+
email: str
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Create
|
|
316
|
+
user = User.objects.create(name="Alice", email="alice@example.com")
|
|
317
|
+
|
|
318
|
+
# Read
|
|
319
|
+
user = User.objects.get(1)
|
|
320
|
+
user = User.objects.get(email="alice@example.com")
|
|
321
|
+
|
|
322
|
+
# Update
|
|
323
|
+
user.name = "Alicia"
|
|
324
|
+
user.save()
|
|
325
|
+
|
|
326
|
+
# Delete
|
|
327
|
+
user.delete()
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Primary-key conventions:
|
|
331
|
+
|
|
332
|
+
- `id: int | None = None` gives the model a database-managed autoincrement primary key.
|
|
333
|
+
- `id: int` is treated as a manual primary key and must be supplied explicitly.
|
|
334
|
+
- `create(id=...)` is rejected for database-managed keys.
|
|
335
|
+
- Persisted primary keys are immutable once the record exists.
|
|
336
|
+
|
|
337
|
+
### CRUD API
|
|
338
|
+
|
|
339
|
+
All write operations live on the manager (`Model.objects`). Three instance methods — `save()`, `delete()`, and `refresh()` — are injected directly onto model instances for convenience.
|
|
340
|
+
|
|
341
|
+
#### Manager operations
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
# Strict insert — raises DuplicateKeyError on collision
|
|
345
|
+
user = User.objects.create(name="Bob", email="bob@example.com")
|
|
346
|
+
|
|
347
|
+
# Alias for callers who want explicit strict-insert wording
|
|
348
|
+
user = User.objects.strict_create(name="Bob", email="bob@example.com")
|
|
349
|
+
|
|
350
|
+
# Atomic upsert — INSERT … ON CONFLICT DO UPDATE, no race conditions
|
|
351
|
+
user = User.objects.upsert(id=1, name="Bob", email="bob@example.com")
|
|
352
|
+
|
|
353
|
+
# If no primary key is supplied, upsert falls back to configured unique fields
|
|
354
|
+
user = User.objects.upsert(name="Bob", email="bob@example.com")
|
|
355
|
+
|
|
356
|
+
# Bulk field update — returns refreshed records
|
|
357
|
+
updated = User.objects.update_where({"role": "trial"}, role="active")
|
|
358
|
+
|
|
359
|
+
# Delete by primary key
|
|
360
|
+
User.objects.delete(user_id)
|
|
361
|
+
|
|
362
|
+
# Delete by criteria — returns row count
|
|
363
|
+
count = User.objects.delete_where(role="inactive")
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
#### Instance operations
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
# Upsert this instance
|
|
370
|
+
user.save()
|
|
371
|
+
|
|
372
|
+
# Persisted primary keys are immutable
|
|
373
|
+
# user.id = 999
|
|
374
|
+
# user.save() # -> ImmutableFieldError
|
|
375
|
+
|
|
376
|
+
# Delete this instance's row
|
|
377
|
+
user.delete()
|
|
378
|
+
|
|
379
|
+
# Re-fetch from the database (raises RecordNotFoundError if gone)
|
|
380
|
+
fresh = user.refresh()
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Querying
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
# All rows
|
|
387
|
+
users = User.objects.all()
|
|
388
|
+
|
|
389
|
+
# Filter with equality criteria
|
|
390
|
+
admins = User.objects.filter(role="admin")
|
|
391
|
+
|
|
392
|
+
# Filter values are validated against the declared field types
|
|
393
|
+
# User.objects.filter(role=123) # -> InvalidQueryError if the type is invalid
|
|
394
|
+
|
|
395
|
+
# Pagination
|
|
396
|
+
page = User.objects.filter(role="active", limit=20, offset=40)
|
|
397
|
+
|
|
398
|
+
# First / last
|
|
399
|
+
newest = User.objects.last()
|
|
400
|
+
first_trial = User.objects.first(role="trial")
|
|
401
|
+
|
|
402
|
+
# Get one or None
|
|
403
|
+
user = User.objects.get(1)
|
|
404
|
+
user = User.objects.get(email="alice@example.com")
|
|
405
|
+
|
|
406
|
+
# Get or raise RecordNotFoundError
|
|
407
|
+
user = User.objects.require(1)
|
|
408
|
+
|
|
409
|
+
# Existence and count
|
|
410
|
+
exists = User.objects.exists(email="alice@example.com")
|
|
411
|
+
total = User.objects.count(role="active")
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Schema Management
|
|
415
|
+
|
|
416
|
+
Table creation happens automatically on decoration (`auto_create=True` by default). Schema helpers are accessible as both class methods and via the manager:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
# Idempotent table creation
|
|
420
|
+
User.create_schema() # or User.objects.create_schema()
|
|
421
|
+
|
|
422
|
+
# Inspection
|
|
423
|
+
User.schema_exists() # or User.objects.schema_exists()
|
|
424
|
+
|
|
425
|
+
# Destructive operations
|
|
426
|
+
User.truncate() # delete all rows, keep schema
|
|
427
|
+
User.drop_schema() # drop the table entirely
|
|
428
|
+
|
|
429
|
+
# Additive column evolution (no migration framework required)
|
|
430
|
+
User.objects.add_column("verified_at", Optional[datetime])
|
|
431
|
+
User.objects.ensure_column("verified_at", Optional[datetime]) # idempotent
|
|
432
|
+
|
|
433
|
+
# Explicit transaction for batched atomicity
|
|
434
|
+
with User.objects.transaction() as conn:
|
|
435
|
+
User.objects.create(name="Alice", email="alice@example.com")
|
|
436
|
+
Profile.objects.create(user_id=1, bio="...")
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Relationships
|
|
440
|
+
|
|
441
|
+
Relationships are lazy-loaded, read-only descriptors assigned after class decoration. This pattern avoids conflicts with Pydantic's metaclass and naturally resolves forward-reference ordering.
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
from registers.db import database_registry
|
|
445
|
+
from registers.db.relations import HasMany, BelongsTo, HasManyThrough
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@database_registry("store.db", table_name="authors", key_field="id", autoincrement=True)
|
|
449
|
+
class Author(BaseModel):
|
|
450
|
+
id: int | None = None
|
|
451
|
+
name: str
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@database_registry("store.db", table_name="posts", key_field="id", autoincrement=True)
|
|
455
|
+
class Post(BaseModel):
|
|
456
|
+
id: int | None = None
|
|
457
|
+
author_id: int
|
|
458
|
+
title: str
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@database_registry("store.db", table_name="post_tags", key_field="id", autoincrement=True)
|
|
462
|
+
class PostTag(BaseModel):
|
|
463
|
+
id: int | None = None
|
|
464
|
+
post_id: int
|
|
465
|
+
tag_id: int
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@database_registry("store.db", table_name="tags", key_field="id", autoincrement=True)
|
|
469
|
+
class Tag(BaseModel):
|
|
470
|
+
id: int | None = None
|
|
471
|
+
name: str
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# Optionally declare relationships after all classes are decorated (not required)
|
|
475
|
+
Author.posts = HasMany(Post, foreign_key="author_id")
|
|
476
|
+
Post.author = BelongsTo(Author, local_key="author_id")
|
|
477
|
+
Post.tags = HasManyThrough(Tag, through=PostTag, source_key="post_id", target_key="tag_id")
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
author = Author.objects.require(1)
|
|
482
|
+
author.posts # → list[Post]
|
|
483
|
+
|
|
484
|
+
post = Post.objects.require(1)
|
|
485
|
+
post.author # → Author | None
|
|
486
|
+
post.tags # → list[Tag]
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
| Descriptor | Relationship | Example |
|
|
490
|
+
|---|---|---|
|
|
491
|
+
| `HasMany` | One-to-many | `Author → Posts` |
|
|
492
|
+
| `BelongsTo` | Many-to-one | `Post → Author` |
|
|
493
|
+
| `HasManyThrough` | Many-to-many via join table | `Post ↔ Tags` |
|
|
494
|
+
|
|
495
|
+
### FastAPI Integration
|
|
496
|
+
|
|
497
|
+
`registers.db` integrates cleanly with FastAPI's `lifespan` pattern for schema initialization and engine disposal:
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
import logging
|
|
501
|
+
from contextlib import asynccontextmanager
|
|
502
|
+
from fastapi import FastAPI
|
|
503
|
+
from models import User, Product, Order
|
|
504
|
+
|
|
505
|
+
def initialize_schemas():
|
|
506
|
+
"""Create every table schema exactly once on app startup (idempotent)."""
|
|
507
|
+
logging.info(" Initializing ecommerce database schemas...")
|
|
508
|
+
|
|
509
|
+
models = [
|
|
510
|
+
User,
|
|
511
|
+
Product,
|
|
512
|
+
Order,
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
for model in models:
|
|
516
|
+
try:
|
|
517
|
+
# The Production Spec guarantees these schema methods exist on the
|
|
518
|
+
# registry/manager attached to the model. We call them directly on
|
|
519
|
+
# the class (the most ergonomic pattern for FastAPI usage).
|
|
520
|
+
if not model.schema_exists():
|
|
521
|
+
model.create_schema()
|
|
522
|
+
logging.info(f"Schema created - {model.__name__}")
|
|
523
|
+
else:
|
|
524
|
+
logging.info(f"Schema already exists - {model.__name__}")
|
|
525
|
+
except AttributeError:
|
|
526
|
+
# Safety net in case the manager is attached under a different name
|
|
527
|
+
# (e.g. model.manager or model.registry). The core CRUD routes will
|
|
528
|
+
# still work.
|
|
529
|
+
logging.warning(
|
|
530
|
+
f"Schema methods not directly on {model.__name__}. "
|
|
531
|
+
"Manual schema creation may be required."
|
|
532
|
+
)
|
|
533
|
+
except Exception as exc: # catches SchemaError, etc.
|
|
534
|
+
logging.error(f"Failed to initialize {model.__name__}: {exc}")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def dispose_engines():
|
|
538
|
+
"""Dispose all SQLAlchemy engines on app shutdown to close DB connections."""
|
|
539
|
+
logging.info("Disposing database engines...")
|
|
540
|
+
|
|
541
|
+
models = [
|
|
542
|
+
User,
|
|
543
|
+
Product,
|
|
544
|
+
Order,
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
for model in models:
|
|
548
|
+
try:
|
|
549
|
+
if model.schema_exists():
|
|
550
|
+
model.drop_schema()
|
|
551
|
+
logging.info(f"Engine dropped → {model.__name__}")
|
|
552
|
+
else:
|
|
553
|
+
logging.info(f"Engine does not exist → {model.__name__}")
|
|
554
|
+
except Exception as exc: # catches SchemaError, etc.
|
|
555
|
+
logging.error(f"Failed to dispose {model.__name__}: {exc}")
|
|
556
|
+
|
|
557
|
+
def dispose_engines():
|
|
558
|
+
for model in [User, Product, Order]:
|
|
559
|
+
model.objects.dispose()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@asynccontextmanager
|
|
563
|
+
async def lifespan(app: FastAPI):
|
|
564
|
+
initialize_schemas()
|
|
565
|
+
yield
|
|
566
|
+
dispose_engines()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
app = FastAPI(lifespan=lifespan)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app.get("/users/{user_id}", response_model=User)
|
|
573
|
+
async def get_user(user_id: int):
|
|
574
|
+
return User.objects.require(user_id)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.post("/users/", response_model=User)
|
|
578
|
+
async def create_user(user: User):
|
|
579
|
+
return User.objects.create(**user.model_dump(exclude={"id"}))
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Error Reference
|
|
585
|
+
|
|
586
|
+
### registers.cli
|
|
587
|
+
|
|
588
|
+
| Exception | Raised when |
|
|
589
|
+
|---|---|
|
|
590
|
+
| `DuplicateCommandError` | A command name is registered more than once |
|
|
591
|
+
| `UnknownCommandError` | A requested command has no registered handler |
|
|
592
|
+
| `DependencyNotFoundError` | The DI container cannot resolve a required type |
|
|
593
|
+
| `PluginLoadError` | A plugin module fails to import |
|
|
594
|
+
|
|
595
|
+
### registers.db
|
|
596
|
+
|
|
597
|
+
| Exception | Raised when |
|
|
598
|
+
|---|---|
|
|
599
|
+
| `ModelRegistrationError` | The decorated class is not a valid Pydantic `BaseModel` |
|
|
600
|
+
| `ConfigurationError` | Decorator options reference non-existent fields or are invalid |
|
|
601
|
+
| `DuplicateKeyError` | An `INSERT` collides with an existing primary key |
|
|
602
|
+
| `InvalidPrimaryKeyAssignmentError` | A database-managed primary key is assigned explicitly on create |
|
|
603
|
+
| `ImmutableFieldError` | A persisted primary key is mutated and then saved |
|
|
604
|
+
| `UniqueConstraintError` | An `INSERT` or `UPDATE` violates a `UNIQUE` constraint |
|
|
605
|
+
| `RecordNotFoundError` | `require()` finds no matching row |
|
|
606
|
+
| `InvalidQueryError` | Filter criteria reference unknown fields or are malformed |
|
|
607
|
+
| `SchemaError` | A DDL operation (`CREATE` / `DROP` / `ALTER`) fails |
|
|
608
|
+
| `MigrationError` | A schema evolution step cannot be applied |
|
|
609
|
+
| `RelationshipError` | A relationship descriptor is misconfigured or accessed before setup |
|
|
610
|
+
|
|
611
|
+
All exceptions inherit from `FrameworkError` (CLI) or `RegistryError` (DB) for broad catch-all handling.
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
|
|
615
|
+
## Installation
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
pip install registers
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
**Development install (with test dependencies):**
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
pip install "registers[dev]"
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**From source:**
|
|
628
|
+
|
|
629
|
+
```bash
|
|
630
|
+
git clone https://github.com/yourname/registers
|
|
631
|
+
pip install ./registers
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## Requirements
|
|
637
|
+
|
|
638
|
+
- Python ≥ 3.10
|
|
639
|
+
- pydantic ≥ 2.0
|
|
640
|
+
- sqlalchemy ≥ 2.0
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## License
|
|
645
|
+
|
|
646
|
+
MIT
|