domainkit 0.2.0__tar.gz → 0.3.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.
- {domainkit-0.2.0 → domainkit-0.3.0}/PKG-INFO +11 -4
- {domainkit-0.2.0 → domainkit-0.3.0}/README.md +8 -3
- {domainkit-0.2.0 → domainkit-0.3.0}/pyproject.toml +8 -1
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/config.py +10 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/registry.py +7 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/__init__.py +4 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/global_settings.py +158 -3
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/apps.py +12 -2
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/commands/auditlog.py +14 -2
- domainkit-0.3.0/src/domainkit/contrib/evidence/__init__.py +60 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/apps.py +29 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/management/__init__.py +1 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/management/commands/__init__.py +1 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/management/commands/evidence.py +50 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/model.py +166 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/settings.py +16 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/store.py +131 -0
- domainkit-0.3.0/src/domainkit/contrib/evidence/tasks.py +32 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/apps.py +8 -1
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/admin.py +15 -7
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/apps.py +8 -9
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/messages.py +16 -4
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/runworker.py +20 -4
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/worker.py +20 -4
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/connections.py +24 -1
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migrations.py +81 -38
- domainkit-0.3.0/src/domainkit/db/partitioning.py +344 -0
- domainkit-0.3.0/src/domainkit/db/routing.py +216 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/uow.py +35 -7
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/application.py +3 -1
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/health.py +2 -0
- domainkit-0.3.0/src/domainkit/ledger/__init__.py +37 -0
- domainkit-0.3.0/src/domainkit/ledger/base.py +75 -0
- domainkit-0.3.0/src/domainkit/ledger/handler.py +76 -0
- domainkit-0.3.0/src/domainkit/ledger/immudb.py +233 -0
- domainkit-0.3.0/src/domainkit/ledger/memory.py +42 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/check.py +6 -5
- domainkit-0.3.0/src/domainkit/management/commands/makemigrations.py +89 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/migrate.py +31 -10
- domainkit-0.3.0/src/domainkit/management/commands/partitions.py +55 -0
- domainkit-0.3.0/src/domainkit/management/commands/showmigrations.py +35 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/orm.py-tpl +2 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/testing.py +14 -10
- domainkit-0.2.0/src/domainkit/management/commands/makemigrations.py +0 -61
- domainkit-0.2.0/src/domainkit/management/commands/showmigrations.py +0 -26
- {domainkit-0.2.0 → domainkit-0.3.0}/LICENSE +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/apps/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/architecture.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/env.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/conf/lazy.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/context.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/commands/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/protection.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/recorder.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/settings.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/tables.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/middleware.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/settings.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/tables.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/idempotency/tasks.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/management/commands/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/metrics.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/outbox.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/registry.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/settings.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/messaging/tables.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/drivers.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/env.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/migration_env/script.py.mako +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/orm.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/repository.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/db/types.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/_dataclass.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/events.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/exceptions.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/messages.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/model.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/pagination.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/repository.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/ddd/uow.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/deprecation.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/exceptions.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/errors.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/metrics.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/middleware.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/pagination.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/http/ratelimit.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/log.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/base.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/init.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/runserver.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/shell.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/commands/startdomain.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/dependencies.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/routes.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/api/schemas.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/commands.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/handlers.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/services.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/application/unit_of_work.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/apps.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/events.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/exceptions.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/model.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/domain/repositories.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/repositories.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/infrastructure/unit_of_work.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/commands/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/management/commands/{{ aggregates }}.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/package/settings.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/fakes.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_api.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_model.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_services.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/domain/tests/test_settings.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/dot-env-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/dot-gitignore-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/manage.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/pyproject.toml-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/asgi.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/base.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/local.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/production.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/settings/test.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/config/urls.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/src/domains/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/tests/__init__.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/management/templates/project/tests/test_health.py-tpl +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/py.typed +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/reporting.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/tracing.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/__init__.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/formatting.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/module_loading.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/rates.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/readiness.py +0 -0
- {domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/utils/text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: domainkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Django-style framework for domain-driven FastAPI services.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
+
Provides-Extra: immudb
|
|
19
20
|
Provides-Extra: metrics
|
|
20
21
|
Provides-Extra: postgres
|
|
21
22
|
Provides-Extra: sqlite
|
|
@@ -26,6 +27,7 @@ Requires-Dist: alembic (>=1.20.0,<2.0.0)
|
|
|
26
27
|
Requires-Dist: asyncpg (>=0.31.0,<0.32.0) ; extra == "postgres"
|
|
27
28
|
Requires-Dist: fastapi (>=0.141.1,<0.142.0)
|
|
28
29
|
Requires-Dist: httpx2 (>=2.13.0,<3.0.0) ; extra == "testing"
|
|
30
|
+
Requires-Dist: immudb-py (>=1.5.0,<1.6.0) ; extra == "immudb"
|
|
29
31
|
Requires-Dist: opentelemetry-api (>=1.45.1,<2.0.0) ; extra == "tracing"
|
|
30
32
|
Requires-Dist: prometheus-client (>=0.26.0,<0.27.0) ; extra == "metrics"
|
|
31
33
|
Requires-Dist: pydantic (>=2.14.1,<3.0.0)
|
|
@@ -46,17 +48,22 @@ A Django-style framework for domain-driven FastAPI services. It provides:
|
|
|
46
48
|
- settings modules and installed domains
|
|
47
49
|
- `manage.py` commands and migrations
|
|
48
50
|
- DDD building blocks, with the layer rules between them enforced
|
|
49
|
-
-
|
|
51
|
+
- several databases: domains routed to their own, sharding by key,
|
|
52
|
+
PostgreSQL range partitioning, and immudb ledgers for evidence
|
|
53
|
+
- optional apps for reliable messaging, audit trails, idempotent requests,
|
|
54
|
+
and tamper-evident evidence
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
|
-
pip install "domainkit[postgres]" # extras: postgres, sqlite, metrics, tracing, testing
|
|
57
|
+
pip install "domainkit[postgres]" # extras: postgres, sqlite, immudb, metrics, tracing, testing
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
## Documentation
|
|
56
61
|
|
|
57
62
|
- [docs/reference.md](https://github.com/karimmattar/domainkit/blob/main/docs/reference.md): settings, middleware, errors,
|
|
58
63
|
observability, testing, commands, and extension points
|
|
59
|
-
- [docs/
|
|
64
|
+
- [docs/databases.md](https://github.com/karimmattar/domainkit/blob/main/docs/databases.md): several databases, routing,
|
|
65
|
+
sharding, partitioning, and ledgers
|
|
66
|
+
- [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, idempotency, and evidence
|
|
60
67
|
- [docs/versioning.md](https://github.com/karimmattar/domainkit/blob/main/docs/versioning.md): compatibility, deprecations, and releases
|
|
61
68
|
- [docs/adr/](https://github.com/karimmattar/domainkit/tree/main/docs/adr): the decisions behind the design
|
|
62
69
|
- [CHANGELOG.md](https://github.com/karimmattar/domainkit/blob/main/CHANGELOG.md) and [SECURITY.md](https://github.com/karimmattar/domainkit/blob/main/SECURITY.md)
|
|
@@ -5,17 +5,22 @@ A Django-style framework for domain-driven FastAPI services. It provides:
|
|
|
5
5
|
- settings modules and installed domains
|
|
6
6
|
- `manage.py` commands and migrations
|
|
7
7
|
- DDD building blocks, with the layer rules between them enforced
|
|
8
|
-
-
|
|
8
|
+
- several databases: domains routed to their own, sharding by key,
|
|
9
|
+
PostgreSQL range partitioning, and immudb ledgers for evidence
|
|
10
|
+
- optional apps for reliable messaging, audit trails, idempotent requests,
|
|
11
|
+
and tamper-evident evidence
|
|
9
12
|
|
|
10
13
|
```bash
|
|
11
|
-
pip install "domainkit[postgres]" # extras: postgres, sqlite, metrics, tracing, testing
|
|
14
|
+
pip install "domainkit[postgres]" # extras: postgres, sqlite, immudb, metrics, tracing, testing
|
|
12
15
|
```
|
|
13
16
|
|
|
14
17
|
## Documentation
|
|
15
18
|
|
|
16
19
|
- [docs/reference.md](https://github.com/karimmattar/domainkit/blob/main/docs/reference.md): settings, middleware, errors,
|
|
17
20
|
observability, testing, commands, and extension points
|
|
18
|
-
- [docs/
|
|
21
|
+
- [docs/databases.md](https://github.com/karimmattar/domainkit/blob/main/docs/databases.md): several databases, routing,
|
|
22
|
+
sharding, partitioning, and ledgers
|
|
23
|
+
- [docs/contrib.md](https://github.com/karimmattar/domainkit/blob/main/docs/contrib.md): messaging, audit, idempotency, and evidence
|
|
19
24
|
- [docs/versioning.md](https://github.com/karimmattar/domainkit/blob/main/docs/versioning.md): compatibility, deprecations, and releases
|
|
20
25
|
- [docs/adr/](https://github.com/karimmattar/domainkit/tree/main/docs/adr): the decisions behind the design
|
|
21
26
|
- [CHANGELOG.md](https://github.com/karimmattar/domainkit/blob/main/CHANGELOG.md) and [SECURITY.md](https://github.com/karimmattar/domainkit/blob/main/SECURITY.md)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "domainkit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "A Django-style framework for domain-driven FastAPI services."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -35,6 +35,8 @@ postgres = ["asyncpg (>=0.31.0,<0.32.0)"]
|
|
|
35
35
|
sqlite = ["aiosqlite (>=0.22.1,<0.23.0)"]
|
|
36
36
|
metrics = ["prometheus-client (>=0.26.0,<0.27.0)"]
|
|
37
37
|
tracing = ["opentelemetry-api (>=1.45.1,<2.0.0)"]
|
|
38
|
+
# Ledgers for evidence (WORM): immudb's SDK, with its verified operations.
|
|
39
|
+
immudb = ["immudb-py (>=1.5.0,<1.6.0)"]
|
|
38
40
|
# The domainkit.testing pytest plugin; Starlette's TestClient needs httpx2.
|
|
39
41
|
testing = ["pytest (>=9.1.1,<10.0.0)", "httpx2 (>=2.13.0,<3.0.0)"]
|
|
40
42
|
|
|
@@ -104,6 +106,11 @@ mypy_path = "src:."
|
|
|
104
106
|
files = ["src", "domainkit_tests"]
|
|
105
107
|
exclude = ["/management/templates/", "/mutants/"]
|
|
106
108
|
|
|
109
|
+
# immudb-py and grpcio ship no type information.
|
|
110
|
+
[[tool.mypy.overrides]]
|
|
111
|
+
module = ["immudb", "immudb.*", "grpc", "grpc.*", "ecdsa", "ecdsa.*"]
|
|
112
|
+
ignore_missing_imports = true
|
|
113
|
+
|
|
107
114
|
[tool.pytest.ini_options]
|
|
108
115
|
testpaths = ["domainkit_tests"]
|
|
109
116
|
pythonpath = ["."]
|
|
@@ -11,6 +11,7 @@ from pydantic import BaseModel
|
|
|
11
11
|
from domainkit.exceptions import ImproperlyConfigured
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
+
from domainkit.db.routing import DatabaseRouter
|
|
14
15
|
from domainkit.ddd import DeferredTask, DomainEvent, UnitOfWork
|
|
15
16
|
|
|
16
17
|
CONFIG_MODULE = "apps"
|
|
@@ -100,6 +101,15 @@ class DomainConfig:
|
|
|
100
101
|
) -> None:
|
|
101
102
|
"""Add rows to a committing SQL unit of work (outbox, audit trail)."""
|
|
102
103
|
|
|
104
|
+
def databases(self, router: "DatabaseRouter") -> frozenset[str]:
|
|
105
|
+
"""The targets that hold this domain's tables (see ``DATABASE_ROUTING``).
|
|
106
|
+
|
|
107
|
+
By default, the domain's routing target. Apps whose rows must commit
|
|
108
|
+
with every domain's data (the outbox, the audit trail) return every
|
|
109
|
+
hosting target; apps without tables return none.
|
|
110
|
+
"""
|
|
111
|
+
return frozenset({router.target_for(self.label)})
|
|
112
|
+
|
|
103
113
|
|
|
104
114
|
def _import_domain(name: str) -> ModuleType:
|
|
105
115
|
"""Import a domain package, explaining a missing one."""
|
|
@@ -123,6 +123,13 @@ class DomainRegistry:
|
|
|
123
123
|
+ ", ".join(_describe_unknown(name, known) for name in unknown)
|
|
124
124
|
+ ". Declare them in a domain's settings_class or SETTINGS_CLASS."
|
|
125
125
|
)
|
|
126
|
+
labels = list(self._configs)
|
|
127
|
+
if unrouted := sorted(set(current.DATABASE_ROUTING) - set(labels)):
|
|
128
|
+
raise ImproperlyConfigured(
|
|
129
|
+
"DATABASE_ROUTING names domains that are not installed: "
|
|
130
|
+
+ ", ".join(_describe_unknown(label, labels) for label in unrouted)
|
|
131
|
+
+ "."
|
|
132
|
+
)
|
|
126
133
|
|
|
127
134
|
|
|
128
135
|
def _describe_unknown(name: str, known: list[str]) -> str:
|
|
@@ -20,7 +20,9 @@ from domainkit.conf.env import Env
|
|
|
20
20
|
from domainkit.conf.global_settings import (
|
|
21
21
|
DEFAULT_DB_ALIAS,
|
|
22
22
|
DatabaseConfig,
|
|
23
|
+
LedgerConfig,
|
|
23
24
|
Settings,
|
|
25
|
+
ShardConfig,
|
|
24
26
|
)
|
|
25
27
|
from domainkit.conf.lazy import (
|
|
26
28
|
SETTINGS_MODULE_VARIABLE,
|
|
@@ -46,7 +48,9 @@ __all__ = [
|
|
|
46
48
|
"SETTINGS_MODULE_VARIABLE",
|
|
47
49
|
"UNSET",
|
|
48
50
|
"DatabaseConfig",
|
|
51
|
+
"LedgerConfig",
|
|
49
52
|
"Settings",
|
|
53
|
+
"ShardConfig",
|
|
50
54
|
"configure",
|
|
51
55
|
"describe_errors",
|
|
52
56
|
"load_settings",
|
|
@@ -9,6 +9,7 @@ so a misspelled setting fails at startup instead of being silently ignored.
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Final, Literal, Self
|
|
12
|
+
from urllib.parse import urlsplit
|
|
12
13
|
|
|
13
14
|
from pydantic import (
|
|
14
15
|
BaseModel,
|
|
@@ -51,6 +52,11 @@ def is_network_database(url: str) -> bool:
|
|
|
51
52
|
return not url.split(":", 1)[0].split("+", 1)[0].startswith("sqlite")
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
LEDGER_SCHEMES: Final = frozenset({"immudb", "memory"})
|
|
56
|
+
"""URL schemes of ledgers (``LEDGERS``); ``DATABASES`` refuses them."""
|
|
57
|
+
IMMUDB_PORT: Final = 3322
|
|
58
|
+
|
|
59
|
+
|
|
54
60
|
class DatabaseConfig(BaseModel):
|
|
55
61
|
"""One ``DATABASES`` entry: an SQLAlchemy async URL plus connection options."""
|
|
56
62
|
|
|
@@ -95,6 +101,108 @@ class DatabaseConfig(BaseModel):
|
|
|
95
101
|
raise ValueError("SSLCERT and SSLKEY must be set together")
|
|
96
102
|
return self
|
|
97
103
|
|
|
104
|
+
@model_validator(mode="after")
|
|
105
|
+
def check_not_a_ledger(self) -> Self:
|
|
106
|
+
"""Point ledger URLs to ``LEDGERS``, where they belong."""
|
|
107
|
+
scheme = urlsplit(self.URL.get_secret_value()).scheme.split("+", 1)[0]
|
|
108
|
+
if scheme in LEDGER_SCHEMES:
|
|
109
|
+
raise ValueError(f"{scheme}:// is a ledger; configure it in LEDGERS")
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class LedgerConfig(BaseModel):
|
|
114
|
+
"""One ``LEDGERS`` entry: write once, read many, verified on the client.
|
|
115
|
+
|
|
116
|
+
Ledgers hold evidence (see ``domainkit.contrib.evidence``). They are never
|
|
117
|
+
migrated and never take part in a unit of work.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
121
|
+
|
|
122
|
+
URL: SecretStr
|
|
123
|
+
"""``immudb://user:password@host:3322/database``, or ``memory://`` for tests."""
|
|
124
|
+
TLS_CA: Path | None = None
|
|
125
|
+
"""CA certificate that signed the server's; setting it enables TLS."""
|
|
126
|
+
SERVER_SIGNING_KEY: Path | None = None
|
|
127
|
+
"""Public key the server signs its states with (immudb ``--signingKey``)."""
|
|
128
|
+
TIMEOUT: float = Field(default=10.0, gt=0)
|
|
129
|
+
"""Seconds each ledger call may take."""
|
|
130
|
+
|
|
131
|
+
@model_validator(mode="after")
|
|
132
|
+
def check_url(self) -> Self:
|
|
133
|
+
"""Accept immudb URLs with a host, or the in-memory ledger."""
|
|
134
|
+
parts = urlsplit(self.URL.get_secret_value())
|
|
135
|
+
if parts.scheme not in LEDGER_SCHEMES:
|
|
136
|
+
raise ValueError("a ledger URL starts with immudb:// or memory://")
|
|
137
|
+
if parts.scheme == "immudb" and not parts.hostname:
|
|
138
|
+
raise ValueError("an immudb URL needs a host: immudb://user:pw@host/db")
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def scheme(self) -> str:
|
|
143
|
+
"""``immudb`` or ``memory``."""
|
|
144
|
+
return urlsplit(self.URL.get_secret_value()).scheme
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ShardConfig(BaseModel):
|
|
148
|
+
"""One ``SHARDS`` group: SQL databases that share a schema, split by key.
|
|
149
|
+
|
|
150
|
+
A shard key hashes to one of ``BUCKETS`` buckets, and each bucket belongs
|
|
151
|
+
to one database. Moving buckets between databases is how a group grows.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
155
|
+
|
|
156
|
+
DATABASES: tuple[str, ...] = Field(min_length=1)
|
|
157
|
+
"""The group's SQL aliases; ``makemigrations`` compares against the first."""
|
|
158
|
+
BUCKETS: int = Field(default=4096, ge=1, le=1_048_576)
|
|
159
|
+
"""Number of hash buckets. Never change it once the group holds data."""
|
|
160
|
+
RANGES: dict[str, tuple[tuple[int, int], ...]] = Field(default_factory=dict)
|
|
161
|
+
"""Inclusive bucket ranges per alias; default: equal ranges in list order."""
|
|
162
|
+
PINNED: dict[str, str] = Field(default_factory=dict)
|
|
163
|
+
"""Shard keys (as text) always routed to an alias, such as a large tenant."""
|
|
164
|
+
|
|
165
|
+
@model_validator(mode="after")
|
|
166
|
+
def check_layout(self) -> Self:
|
|
167
|
+
"""Every bucket has exactly one owner, and pins stay inside the group."""
|
|
168
|
+
if len(set(self.DATABASES)) != len(self.DATABASES):
|
|
169
|
+
raise ValueError("a shard group lists an alias twice")
|
|
170
|
+
if len(self.DATABASES) > self.BUCKETS:
|
|
171
|
+
raise ValueError("BUCKETS must be at least the number of DATABASES")
|
|
172
|
+
if outside := sorted(set(self.PINNED.values()) - set(self.DATABASES)):
|
|
173
|
+
raise ValueError(f"PINNED names aliases outside the group: {outside}")
|
|
174
|
+
if not self.RANGES:
|
|
175
|
+
return self
|
|
176
|
+
if set(self.RANGES) != set(self.DATABASES):
|
|
177
|
+
raise ValueError("RANGES must give ranges for exactly the group's aliases")
|
|
178
|
+
owned = sorted(
|
|
179
|
+
(low, high) for ranges in self.RANGES.values() for low, high in ranges
|
|
180
|
+
)
|
|
181
|
+
expected = 0
|
|
182
|
+
for low, high in owned:
|
|
183
|
+
if low != expected or high < low:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"RANGES must cover buckets 0-{self.BUCKETS - 1} once each; "
|
|
186
|
+
f"bucket {expected} is missing or overlapped"
|
|
187
|
+
)
|
|
188
|
+
expected = high + 1
|
|
189
|
+
if expected != self.BUCKETS:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"RANGES must cover buckets 0-{self.BUCKETS - 1} once each; "
|
|
192
|
+
f"they end at {expected - 1}"
|
|
193
|
+
)
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def ranges(self) -> dict[str, tuple[tuple[int, int], ...]]:
|
|
197
|
+
"""Bucket ranges per alias: ``RANGES``, or equal contiguous ranges."""
|
|
198
|
+
if self.RANGES:
|
|
199
|
+
return dict(self.RANGES)
|
|
200
|
+
count, buckets = len(self.DATABASES), self.BUCKETS
|
|
201
|
+
return {
|
|
202
|
+
alias: ((index * buckets // count, (index + 1) * buckets // count - 1),)
|
|
203
|
+
for index, alias in enumerate(self.DATABASES)
|
|
204
|
+
}
|
|
205
|
+
|
|
98
206
|
|
|
99
207
|
class Settings(BaseModel):
|
|
100
208
|
"""Validated, read-only settings; extra names are checked at ``setup()``."""
|
|
@@ -120,8 +228,15 @@ class Settings(BaseModel):
|
|
|
120
228
|
"""``module:attribute`` that ``runserver`` serves."""
|
|
121
229
|
|
|
122
230
|
DATABASES: dict[str, DatabaseConfig] = Field(default_factory=dict)
|
|
231
|
+
"""SQL databases by alias; if any are set, ``default`` is required."""
|
|
232
|
+
LEDGERS: dict[str, LedgerConfig] = Field(default_factory=dict)
|
|
233
|
+
"""Ledgers by alias: write once, read many (``immudb://``, ``memory://``)."""
|
|
234
|
+
SHARDS: dict[str, ShardConfig] = Field(default_factory=dict)
|
|
235
|
+
"""Shard groups by name; route a domain to one in ``DATABASE_ROUTING``."""
|
|
236
|
+
DATABASE_ROUTING: dict[str, str] = Field(default_factory=dict)
|
|
237
|
+
"""Domain label to target: a SQL alias or a shard group; others use default."""
|
|
123
238
|
MIGRATIONS_DIR: Path = Field(default_factory=lambda: project_root() / "migrations")
|
|
124
|
-
"""
|
|
239
|
+
"""Revisions of ``default``; any other target uses ``MIGRATIONS_DIR/<target>``."""
|
|
125
240
|
LOGGING: dict[str, Any] = Field(default_factory=_minimal_logging)
|
|
126
241
|
"""``logging.config.dictConfig`` mapping applied by ``setup()``."""
|
|
127
242
|
|
|
@@ -171,8 +286,7 @@ class Settings(BaseModel):
|
|
|
171
286
|
@model_validator(mode="after")
|
|
172
287
|
def check_consistency(self) -> Self:
|
|
173
288
|
"""Reject combinations that are unsafe or would fail later at runtime."""
|
|
174
|
-
|
|
175
|
-
raise ValueError(f"DATABASES must define the {DEFAULT_DB_ALIAS!r} alias")
|
|
289
|
+
self._check_databases()
|
|
176
290
|
if len(set(self.INSTALLED_DOMAINS)) != len(self.INSTALLED_DOMAINS):
|
|
177
291
|
raise ValueError("INSTALLED_DOMAINS contains duplicates")
|
|
178
292
|
if self.CORS_ALLOW_CREDENTIALS and "*" in self.CORS_ALLOWED_ORIGINS:
|
|
@@ -183,6 +297,34 @@ class Settings(BaseModel):
|
|
|
183
297
|
self._check_deployment()
|
|
184
298
|
return self
|
|
185
299
|
|
|
300
|
+
def _check_databases(self) -> None:
|
|
301
|
+
"""Validate aliases, shard groups, and routing targets together."""
|
|
302
|
+
sql = self.DATABASES
|
|
303
|
+
if sql and DEFAULT_DB_ALIAS not in sql:
|
|
304
|
+
raise ValueError(f"DATABASES must define the {DEFAULT_DB_ALIAS!r} alias")
|
|
305
|
+
sharded: dict[str, str] = {}
|
|
306
|
+
for group, shards in self.SHARDS.items():
|
|
307
|
+
if group in self.DATABASES:
|
|
308
|
+
raise ValueError(f"shard group {group!r} has the name of an alias")
|
|
309
|
+
for alias in shards.DATABASES:
|
|
310
|
+
if alias not in sql or alias == DEFAULT_DB_ALIAS:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"shard group {group!r}: {alias!r} must be a SQL alias "
|
|
313
|
+
f"other than {DEFAULT_DB_ALIAS!r}"
|
|
314
|
+
)
|
|
315
|
+
if alias in sharded:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"{alias!r} is in shard groups {sharded[alias]!r} and {group!r}"
|
|
318
|
+
)
|
|
319
|
+
sharded[alias] = group
|
|
320
|
+
for label, target in self.DATABASE_ROUTING.items():
|
|
321
|
+
if target in self.SHARDS or (target in sql and target not in sharded):
|
|
322
|
+
continue
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"DATABASE_ROUTING[{label!r}] = {target!r} must name a shard group "
|
|
325
|
+
"or a SQL alias outside the shard groups"
|
|
326
|
+
)
|
|
327
|
+
|
|
186
328
|
def _check_deployment(self) -> None:
|
|
187
329
|
"""Enforce the security baseline for staging and production."""
|
|
188
330
|
where = f"in {self.ENVIRONMENT}"
|
|
@@ -203,3 +345,16 @@ class Settings(BaseModel):
|
|
|
203
345
|
for message, violates in rules.items():
|
|
204
346
|
if aliases := sorted(a for a, db in self.DATABASES.items() if violates(db)):
|
|
205
347
|
raise ValueError(f"{message} {where} for {aliases}")
|
|
348
|
+
ledger_rules: dict[str, Callable[[LedgerConfig], bool]] = {
|
|
349
|
+
"memory:// ledgers are for tests; use immudb": lambda ledger: (
|
|
350
|
+
ledger.scheme == "memory"
|
|
351
|
+
),
|
|
352
|
+
"ledgers need TLS_CA (TLS)": lambda ledger: ledger.TLS_CA is None,
|
|
353
|
+
"ledgers need SERVER_SIGNING_KEY (signed states)": lambda ledger: (
|
|
354
|
+
ledger.SERVER_SIGNING_KEY is None
|
|
355
|
+
),
|
|
356
|
+
}
|
|
357
|
+
for message, violates_ledger in ledger_rules.items():
|
|
358
|
+
ledgers = self.LEDGERS.items()
|
|
359
|
+
if aliases := sorted(a for a, db in ledgers if violates_ledger(db)):
|
|
360
|
+
raise ValueError(f"{message} {where} for LEDGERS {aliases}")
|
|
@@ -5,6 +5,7 @@ from typing import cast
|
|
|
5
5
|
|
|
6
6
|
from domainkit.apps import DomainConfig, registry
|
|
7
7
|
from domainkit.db import SqlAlchemyUnitOfWork
|
|
8
|
+
from domainkit.db.routing import DatabaseRouter, router
|
|
8
9
|
from domainkit.ddd import DeferredTask, DomainEvent, UnitOfWork
|
|
9
10
|
|
|
10
11
|
from .recorder import event_rows, write
|
|
@@ -18,6 +19,11 @@ class AuditConfig(DomainConfig):
|
|
|
18
19
|
verbose_name = "Audit trail"
|
|
19
20
|
settings_class = AuditSettings
|
|
20
21
|
|
|
22
|
+
def ready(self) -> None:
|
|
23
|
+
"""Check where explicit records go."""
|
|
24
|
+
config = registry.settings(AuditSettings)
|
|
25
|
+
router.require_standalone(config.DATABASE, "AUDIT.DATABASE")
|
|
26
|
+
|
|
21
27
|
async def stage(
|
|
22
28
|
self,
|
|
23
29
|
uow: UnitOfWork,
|
|
@@ -26,7 +32,11 @@ class AuditConfig(DomainConfig):
|
|
|
26
32
|
) -> None:
|
|
27
33
|
"""Add one audit row per committed event (unless excluded)."""
|
|
28
34
|
config = registry.settings(AuditSettings)
|
|
29
|
-
|
|
30
|
-
if not config.EVENTS or sql_uow.database != config.DATABASE:
|
|
35
|
+
if not config.EVENTS:
|
|
31
36
|
return
|
|
37
|
+
sql_uow = cast(SqlAlchemyUnitOfWork, uow)
|
|
32
38
|
await write(sql_uow.session, event_rows(events, config))
|
|
39
|
+
|
|
40
|
+
def databases(self, router: DatabaseRouter) -> frozenset[str]:
|
|
41
|
+
"""Every hosting target: events are audited where they commit."""
|
|
42
|
+
return frozenset(router.hosting_targets())
|
{domainkit-0.2.0 → domainkit-0.3.0}/src/domainkit/contrib/audit/management/commands/auditlog.py
RENAMED
|
@@ -21,9 +21,20 @@ class Command(BaseCommand):
|
|
|
21
21
|
parser.add_argument("--actor")
|
|
22
22
|
parser.add_argument("--action")
|
|
23
23
|
parser.add_argument("--limit", type=int, default=50)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--database",
|
|
26
|
+
help="the alias to read (default: AUDIT.DATABASE); events are audited "
|
|
27
|
+
"in the database where they commit",
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
async def handle(
|
|
26
|
-
self,
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
actor: str | None,
|
|
34
|
+
action: str | None,
|
|
35
|
+
limit: int,
|
|
36
|
+
database: str | None,
|
|
37
|
+
**options: object,
|
|
27
38
|
) -> None:
|
|
28
39
|
"""Print one line per entry."""
|
|
29
40
|
config = registry.settings(AuditSettings)
|
|
@@ -32,7 +43,8 @@ class Command(BaseCommand):
|
|
|
32
43
|
statement = statement.where(audit_log.c.actor == actor)
|
|
33
44
|
if action:
|
|
34
45
|
statement = statement.where(audit_log.c.action == action)
|
|
35
|
-
|
|
46
|
+
alias = database or config.DATABASE
|
|
47
|
+
async with connections.engine(alias).connect() as connection:
|
|
36
48
|
rows = (await connection.execute(statement.limit(limit))).all()
|
|
37
49
|
for row in rows:
|
|
38
50
|
self.write(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Evidence: facts kept on a ledger, provably unchanged (see ADR 0011).
|
|
2
|
+
|
|
3
|
+
Install ``domainkit.contrib.evidence``, configure a ledger, and record::
|
|
4
|
+
|
|
5
|
+
LEDGERS = {"evidence": {"URL": "immudb://user:pw@immudb:3322/evidence"}}
|
|
6
|
+
|
|
7
|
+
store = evidence_store()
|
|
8
|
+
receipt = await store.record(
|
|
9
|
+
Evidence(kind="kyc.document_verified", subject="customer:42",
|
|
10
|
+
facts={"result": "match"}, artifacts={"passport": sha256(data)})
|
|
11
|
+
)
|
|
12
|
+
await store.verify(receipt)
|
|
13
|
+
|
|
14
|
+
Inside a use case, ``defer_evidence(uow, evidence)`` records it through the
|
|
15
|
+
outbox once the change commits.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from domainkit.apps import registry
|
|
19
|
+
from domainkit.contrib.evidence.model import (
|
|
20
|
+
Evidence,
|
|
21
|
+
EvidenceConflict,
|
|
22
|
+
EvidenceNotFound,
|
|
23
|
+
EvidenceReceipt,
|
|
24
|
+
EvidenceTampered,
|
|
25
|
+
RecordedEvidence,
|
|
26
|
+
sha256,
|
|
27
|
+
)
|
|
28
|
+
from domainkit.contrib.evidence.settings import EvidenceSettings
|
|
29
|
+
from domainkit.contrib.evidence.store import (
|
|
30
|
+
EvidenceStore,
|
|
31
|
+
LedgerEvidenceStore,
|
|
32
|
+
evidence_key,
|
|
33
|
+
subject_digest,
|
|
34
|
+
)
|
|
35
|
+
from domainkit.ledger import ledgers
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def evidence_store() -> EvidenceStore:
|
|
39
|
+
"""The configured store, on the ledger ``EVIDENCE.LEDGER`` names."""
|
|
40
|
+
config = registry.settings(EvidenceSettings)
|
|
41
|
+
return LedgerEvidenceStore(
|
|
42
|
+
ledgers[config.LEDGER], namespace=config.NAMESPACE, max_bytes=config.MAX_BYTES
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Evidence",
|
|
48
|
+
"EvidenceConflict",
|
|
49
|
+
"EvidenceNotFound",
|
|
50
|
+
"EvidenceReceipt",
|
|
51
|
+
"EvidenceSettings",
|
|
52
|
+
"EvidenceStore",
|
|
53
|
+
"EvidenceTampered",
|
|
54
|
+
"LedgerEvidenceStore",
|
|
55
|
+
"RecordedEvidence",
|
|
56
|
+
"evidence_key",
|
|
57
|
+
"evidence_store",
|
|
58
|
+
"sha256",
|
|
59
|
+
"subject_digest",
|
|
60
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Install ``domainkit.contrib.evidence`` to record evidence on a ledger."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
|
|
5
|
+
from domainkit.apps import DomainConfig, registry
|
|
6
|
+
from domainkit.db.routing import DatabaseRouter
|
|
7
|
+
from domainkit.ledger import ledgers
|
|
8
|
+
|
|
9
|
+
from .settings import EvidenceSettings
|
|
10
|
+
|
|
11
|
+
MESSAGING = "domainkit.contrib.messaging"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvidenceConfig(DomainConfig):
|
|
15
|
+
"""Check the evidence ledger; register durable recording with messaging."""
|
|
16
|
+
|
|
17
|
+
label = "evidence"
|
|
18
|
+
verbose_name = "Evidence"
|
|
19
|
+
settings_class = EvidenceSettings
|
|
20
|
+
|
|
21
|
+
def ready(self) -> None:
|
|
22
|
+
"""Fail fast unless ``EVIDENCE.LEDGER`` names a ledger."""
|
|
23
|
+
ledgers.config(registry.settings(EvidenceSettings).LEDGER)
|
|
24
|
+
if registry.is_installed(MESSAGING):
|
|
25
|
+
import_module(f"{self.name}.tasks")
|
|
26
|
+
|
|
27
|
+
def databases(self, router: DatabaseRouter) -> frozenset[str]:
|
|
28
|
+
"""None: evidence lives on a ledger, not in a SQL database."""
|
|
29
|
+
return frozenset()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Evidence commands."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""``evidence``."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``evidence``: list a subject's evidence, or verify one record."""
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser
|
|
4
|
+
|
|
5
|
+
from domainkit.contrib.evidence import EvidenceTampered, evidence_store
|
|
6
|
+
from domainkit.management import BaseCommand, CommandError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
"""Read evidence back from the ledger, verified."""
|
|
11
|
+
|
|
12
|
+
help = "List a subject's evidence, or verify one record by key."
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
15
|
+
"""Accept a subject to list, or a key to verify."""
|
|
16
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
17
|
+
mode.add_argument("--subject", help="list the evidence about this subject")
|
|
18
|
+
mode.add_argument("--verify", metavar="KEY", help="verify the record under KEY")
|
|
19
|
+
parser.add_argument("--limit", type=int, default=50)
|
|
20
|
+
|
|
21
|
+
async def handle(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
subject: str | None,
|
|
25
|
+
verify: str | None,
|
|
26
|
+
limit: int,
|
|
27
|
+
**options: object,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Print one line per record, or the verified record."""
|
|
30
|
+
store = evidence_store()
|
|
31
|
+
if verify is not None:
|
|
32
|
+
recorded = await store.get(verify)
|
|
33
|
+
try:
|
|
34
|
+
await store.verify(recorded.receipt)
|
|
35
|
+
except EvidenceTampered as exc:
|
|
36
|
+
raise CommandError(str(exc)) from exc
|
|
37
|
+
evidence = recorded.evidence
|
|
38
|
+
self.write(
|
|
39
|
+
f"Intact: {evidence.kind} about {evidence.subject} at "
|
|
40
|
+
f"{evidence.recorded_at:%Y-%m-%d %H:%M:%S}, {recorded.receipt.digest}, "
|
|
41
|
+
f"transaction {recorded.receipt.transaction}."
|
|
42
|
+
)
|
|
43
|
+
return
|
|
44
|
+
# The required option group guarantees a subject when not verifying.
|
|
45
|
+
for recorded in await store.for_subject(subject or "", limit=limit):
|
|
46
|
+
state = "intact" if recorded.intact else "WRITTEN AGAIN"
|
|
47
|
+
self.write(
|
|
48
|
+
f"{recorded.receipt.key} {recorded.evidence.kind} "
|
|
49
|
+
f"{recorded.evidence.recorded_at:%Y-%m-%d %H:%M:%S} {state}"
|
|
50
|
+
)
|