domainkit 0.6.0__tar.gz → 0.6.2__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.
Files changed (221) hide show
  1. {domainkit-0.6.0 → domainkit-0.6.2}/PKG-INFO +5 -4
  2. {domainkit-0.6.0 → domainkit-0.6.2}/README.md +4 -3
  3. {domainkit-0.6.0 → domainkit-0.6.2}/pyproject.toml +1 -1
  4. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/conf/env.py +20 -9
  5. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/conf/global_settings.py +58 -8
  6. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/context.py +6 -1
  7. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/commands/auditverify.py +2 -0
  8. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/personal.py +4 -2
  9. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/recorder.py +9 -5
  10. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/seals.py +51 -14
  11. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/settings.py +1 -1
  12. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/__init__.py +2 -0
  13. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/access.py +101 -29
  14. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/admin.py +34 -2
  15. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/api.py +65 -11
  16. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/apps.py +6 -2
  17. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/authentication.py +16 -2
  18. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/backends.py +12 -7
  19. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/events.py +7 -0
  20. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/exceptions.py +5 -1
  21. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/hashers.py +6 -2
  22. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/middleware.py +65 -1
  23. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/repositories.py +32 -0
  24. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/settings.py +3 -3
  25. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/tokens.py +8 -0
  26. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/model.py +2 -3
  27. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/settings.py +1 -1
  28. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/store.py +20 -3
  29. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/middleware.py +51 -19
  30. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/settings.py +8 -1
  31. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/registry.py +12 -6
  32. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/settings.py +1 -1
  33. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/worker.py +36 -3
  34. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/connections.py +60 -15
  35. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/drivers.py +14 -3
  36. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/migrations.py +14 -0
  37. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/snapshots.py +16 -6
  38. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/types.py +183 -33
  39. domainkit-0.6.2/src/domainkit/ddd/personal.py +94 -0
  40. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/errors.py +55 -0
  41. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/health.py +24 -6
  42. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/metrics.py +5 -1
  43. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/middleware.py +136 -21
  44. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/ratelimit.py +56 -27
  45. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/makemigrations.py +4 -0
  46. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/startdomain.py +22 -0
  47. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/settings/production.py-tpl +9 -3
  48. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/testing.py +16 -5
  49. domainkit-0.6.0/src/domainkit/ddd/personal.py +0 -46
  50. {domainkit-0.6.0 → domainkit-0.6.2}/LICENSE +0 -0
  51. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/__init__.py +0 -0
  52. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/apps/__init__.py +0 -0
  53. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/apps/config.py +0 -0
  54. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/apps/registry.py +0 -0
  55. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/architecture.py +0 -0
  56. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/broker/__init__.py +0 -0
  57. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/broker/base.py +0 -0
  58. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/broker/handler.py +0 -0
  59. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/broker/memory.py +0 -0
  60. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/broker/rabbitmq.py +0 -0
  61. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/__init__.py +0 -0
  62. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/base.py +0 -0
  63. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/core.py +0 -0
  64. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/decorators.py +0 -0
  65. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/handler.py +0 -0
  66. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/memory.py +0 -0
  67. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/metrics.py +0 -0
  68. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/cache/redis.py +0 -0
  69. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/conf/__init__.py +0 -0
  70. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/conf/lazy.py +0 -0
  71. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/__init__.py +0 -0
  72. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/__init__.py +0 -0
  73. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/apps.py +0 -0
  74. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/__init__.py +0 -0
  75. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/commands/__init__.py +0 -0
  76. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/commands/auditforget.py +0 -0
  77. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/commands/auditlog.py +0 -0
  78. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/management/commands/auditseal.py +0 -0
  79. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/protection.py +0 -0
  80. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/tables.py +0 -0
  81. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/audit/tasks.py +0 -0
  82. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/common-passwords.NOTICE +0 -0
  83. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/common-passwords.txt.gz +0 -0
  84. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/__init__.py +0 -0
  85. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/commands/__init__.py +0 -0
  86. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/commands/changepassword.py +0 -0
  87. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/commands/cleartokens.py +0 -0
  88. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/commands/createsuperuser.py +0 -0
  89. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/management/commands/createtoken.py +0 -0
  90. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/models.py +0 -0
  91. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/permissions.py +0 -0
  92. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/tables.py +0 -0
  93. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/tasks.py +0 -0
  94. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/uow.py +0 -0
  95. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/users.py +0 -0
  96. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/auth/validators.py +0 -0
  97. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/__init__.py +0 -0
  98. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/apps.py +0 -0
  99. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/models.py +0 -0
  100. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/registry.py +0 -0
  101. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/sync.py +0 -0
  102. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/contenttypes/tables.py +0 -0
  103. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/__init__.py +0 -0
  104. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/apps.py +0 -0
  105. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/management/__init__.py +0 -0
  106. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/management/commands/__init__.py +0 -0
  107. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/management/commands/evidence.py +0 -0
  108. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/evidence/tasks.py +0 -0
  109. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/__init__.py +0 -0
  110. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/apps.py +0 -0
  111. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/operations.py +0 -0
  112. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/tables.py +0 -0
  113. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/idempotency/tasks.py +0 -0
  114. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/__init__.py +0 -0
  115. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/admin.py +0 -0
  116. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/apps.py +0 -0
  117. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/dispatch.py +0 -0
  118. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/management/__init__.py +0 -0
  119. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/management/commands/__init__.py +0 -0
  120. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/management/commands/messages.py +0 -0
  121. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/management/commands/runworker.py +0 -0
  122. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/metrics.py +0 -0
  123. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/outbox.py +0 -0
  124. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/contrib/messaging/tables.py +0 -0
  125. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/__init__.py +0 -0
  126. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/errors.py +0 -0
  127. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/migration_env/__init__.py +0 -0
  128. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/migration_env/env.py +0 -0
  129. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/migration_env/script.py.mako +0 -0
  130. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/orm.py +0 -0
  131. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/partitioning.py +0 -0
  132. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/repository.py +0 -0
  133. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/routing.py +0 -0
  134. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/db/uow.py +0 -0
  135. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/__init__.py +0 -0
  136. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/_dataclass.py +0 -0
  137. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/events.py +0 -0
  138. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/exceptions.py +0 -0
  139. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/messages.py +0 -0
  140. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/model.py +0 -0
  141. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/pagination.py +0 -0
  142. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/repository.py +0 -0
  143. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ddd/uow.py +0 -0
  144. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/deprecation.py +0 -0
  145. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/exceptions.py +0 -0
  146. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/__init__.py +0 -0
  147. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/application.py +0 -0
  148. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/http/pagination.py +0 -0
  149. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ledger/__init__.py +0 -0
  150. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ledger/base.py +0 -0
  151. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ledger/handler.py +0 -0
  152. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ledger/immudb.py +0 -0
  153. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/ledger/memory.py +0 -0
  154. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/lifecycle.py +0 -0
  155. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/log.py +0 -0
  156. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/__init__.py +0 -0
  157. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/base.py +0 -0
  158. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/__init__.py +0 -0
  159. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/broker.py +0 -0
  160. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/cache.py +0 -0
  161. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/check.py +0 -0
  162. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/init.py +0 -0
  163. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/migrate.py +0 -0
  164. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/partitions.py +0 -0
  165. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/runserver.py +0 -0
  166. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/shell.py +0 -0
  167. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/commands/showmigrations.py +0 -0
  168. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/__init__.py-tpl +0 -0
  169. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/api/__init__.py-tpl +0 -0
  170. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/api/dependencies.py-tpl +0 -0
  171. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/api/routes.py-tpl +0 -0
  172. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/api/schemas.py-tpl +0 -0
  173. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/application/__init__.py-tpl +0 -0
  174. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/application/commands.py-tpl +0 -0
  175. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/application/handlers.py-tpl +0 -0
  176. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/application/services.py-tpl +0 -0
  177. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/application/unit_of_work.py-tpl +0 -0
  178. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/apps.py-tpl +0 -0
  179. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/domain/__init__.py-tpl +0 -0
  180. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/domain/events.py-tpl +0 -0
  181. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/domain/exceptions.py-tpl +0 -0
  182. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/domain/model.py-tpl +0 -0
  183. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/domain/repositories.py-tpl +0 -0
  184. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/infrastructure/__init__.py-tpl +0 -0
  185. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/infrastructure/orm.py-tpl +0 -0
  186. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/infrastructure/repositories.py-tpl +0 -0
  187. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/infrastructure/unit_of_work.py-tpl +0 -0
  188. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/management/__init__.py-tpl +0 -0
  189. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/management/commands/__init__.py-tpl +0 -0
  190. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/management/commands/{{ aggregates }}.py-tpl +0 -0
  191. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/package/settings.py-tpl +0 -0
  192. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/__init__.py-tpl +0 -0
  193. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/fakes.py-tpl +0 -0
  194. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/test_api.py-tpl +0 -0
  195. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/test_model.py-tpl +0 -0
  196. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/test_services.py-tpl +0 -0
  197. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/domain/tests/test_settings.py-tpl +0 -0
  198. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/dot-env-tpl +0 -0
  199. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/dot-gitignore-tpl +0 -0
  200. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/manage.py-tpl +0 -0
  201. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/pyproject.toml-tpl +0 -0
  202. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/__init__.py-tpl +0 -0
  203. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/asgi.py-tpl +0 -0
  204. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/settings/__init__.py-tpl +0 -0
  205. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/settings/base.py-tpl +0 -0
  206. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/settings/local.py-tpl +0 -0
  207. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/settings/test.py-tpl +0 -0
  208. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/config/urls.py-tpl +0 -0
  209. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/src/domains/__init__.py-tpl +0 -0
  210. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/tests/__init__.py-tpl +0 -0
  211. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/management/templates/project/tests/test_health.py-tpl +0 -0
  212. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/py.typed +0 -0
  213. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/reporting.py +0 -0
  214. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/tracing.py +0 -0
  215. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/__init__.py +0 -0
  216. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/closing.py +0 -0
  217. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/formatting.py +0 -0
  218. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/module_loading.py +0 -0
  219. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/rates.py +0 -0
  220. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/readiness.py +0 -0
  221. {domainkit-0.6.0 → domainkit-0.6.2}/src/domainkit/utils/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: domainkit
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: A Django-style framework for domain-driven FastAPI services.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -124,9 +124,10 @@ billing/
124
124
  tests/test_health.py
125
125
  ```
126
126
 
127
- The production settings read `BILLING_API_ALLOWED_HOSTS` and
128
- `BILLING_API_DATABASE_URL` from the environment, and the deployment checks
129
- refuse to start without them, or without verified TLS to the database. They
127
+ The production settings read `BILLING_API_ALLOWED_HOSTS`,
128
+ `BILLING_API_DATABASE_URL`, and `BILLING_API_TRUSTED_PROXIES` (the load
129
+ balancers' networks, or empty) from the environment, and the deployment
130
+ checks refuse to start without them, or without verified TLS to the database. They
130
131
  also turn the API docs off, send HSTS for a year, and limit each caller to
131
132
  300 requests a minute; `BILLING_API_API_DOCS`,
132
133
  `BILLING_API_SECURE_HSTS_SECONDS`, and `BILLING_API_RATE_LIMIT` change that.
@@ -73,9 +73,10 @@ billing/
73
73
  tests/test_health.py
74
74
  ```
75
75
 
76
- The production settings read `BILLING_API_ALLOWED_HOSTS` and
77
- `BILLING_API_DATABASE_URL` from the environment, and the deployment checks
78
- refuse to start without them, or without verified TLS to the database. They
76
+ The production settings read `BILLING_API_ALLOWED_HOSTS`,
77
+ `BILLING_API_DATABASE_URL`, and `BILLING_API_TRUSTED_PROXIES` (the load
78
+ balancers' networks, or empty) from the environment, and the deployment
79
+ checks refuse to start without them, or without verified TLS to the database. They
79
80
  also turn the API docs off, send HSTS for a year, and limit each caller to
80
81
  300 requests a minute; `BILLING_API_API_DOCS`,
81
82
  `BILLING_API_SECURE_HSTS_SECONDS`, and `BILLING_API_RATE_LIMIT` change that.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "domainkit"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "A Django-style framework for domain-driven FastAPI services."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -18,6 +18,7 @@ is not expanded.
18
18
  """
19
19
 
20
20
  import builtins
21
+ import math
21
22
  import os
22
23
  import re
23
24
  import tomllib
@@ -188,7 +189,7 @@ class Env:
188
189
  text = self.raw(name)
189
190
  if text is None:
190
191
  return self._default(name, default)
191
- return self._convert(name, text, builtins.float, "a number")
192
+ return self._convert(name, text, _finite, "a finite number")
192
193
 
193
194
  def bool[D](
194
195
  self, name: builtins.str, default: D | _Missing = MISSING
@@ -202,7 +203,7 @@ class Env:
202
203
  return True
203
204
  if normalized in _FALSE:
204
205
  return False
205
- raise self._invalid(name, text, "a boolean (true/false)")
206
+ raise self._invalid(name, "a boolean (true/false)")
206
207
 
207
208
  def list[D](
208
209
  self, name: builtins.str, default: D | _Missing = MISSING
@@ -243,16 +244,26 @@ class Env:
243
244
  """Convert with ``cast`` or explain which variable is invalid."""
244
245
  try:
245
246
  return cast(text)
246
- except ValueError as exc:
247
- raise self._invalid(name, text, expected) from exc
247
+ except ValueError:
248
+ # "from None": the ValueError would quote the value, maybe a secret.
249
+ raise self._invalid(name, expected) from None
248
250
 
249
251
  def _invalid(
250
- self, name: builtins.str, text: builtins.str, expected: builtins.str
252
+ self, name: builtins.str, expected: builtins.str
251
253
  ) -> ImproperlyConfigured:
252
- """Build the error for a value that cannot be converted."""
253
- return ImproperlyConfigured(
254
- f"{self.prefix}{name} must be {expected}, got {text!r}."
255
- )
254
+ """Build the error for a value that cannot be converted.
255
+
256
+ The value is not quoted: it may be a secret read from ``<NAME>_FILE``.
257
+ """
258
+ return ImproperlyConfigured(f"{self.prefix}{name} must be {expected}.")
259
+
260
+
261
+ def _finite(text: builtins.str) -> builtins.float:
262
+ """Read a number; infinity and NaN are no setting's value."""
263
+ value = builtins.float(text)
264
+ if not math.isfinite(value):
265
+ raise ValueError # _convert explains it, without the value.
266
+ return value
256
267
 
257
268
 
258
269
  def _from_root(path: Path) -> Path:
@@ -6,10 +6,10 @@ the framework does not declare must belong to an installed domain (see
6
6
  so a misspelled setting fails at startup instead of being silently ignored.
7
7
  """
8
8
 
9
- from collections.abc import Callable
9
+ from collections.abc import Callable, Iterator
10
10
  from pathlib import Path
11
11
  from typing import Any, Final, Literal, Self
12
- from urllib.parse import parse_qs, urlsplit
12
+ from urllib.parse import SplitResult, parse_qs, urlsplit
13
13
 
14
14
  from pydantic import (
15
15
  BaseModel,
@@ -17,6 +17,8 @@ from pydantic import (
17
17
  Field,
18
18
  IPvAnyNetwork,
19
19
  SecretStr,
20
+ SerializerFunctionWrapHandler,
21
+ model_serializer,
20
22
  model_validator,
21
23
  )
22
24
 
@@ -24,6 +26,8 @@ from domainkit.conf.env import project_root
24
26
  from domainkit.utils.rates import parse_rate
25
27
 
26
28
  DEFAULT_DB_ALIAS: Final = "default"
29
+ HIDDEN: Final = "**********"
30
+ """What ``repr`` and dumps of the settings show for domain settings."""
27
31
 
28
32
  Environment = Literal["local", "test", "staging", "production"]
29
33
  DEPLOYED_ENVIRONMENTS: Final = frozenset({"staging", "production"})
@@ -61,7 +65,7 @@ IMMUDB_PORT: Final = 3322
61
65
  class DatabaseConfig(BaseModel):
62
66
  """One ``DATABASES`` entry: an SQLAlchemy async URL plus connection options."""
63
67
 
64
- model_config = ConfigDict(frozen=True, extra="forbid")
68
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
65
69
 
66
70
  URL: SecretStr
67
71
  """``dialect+driver://user:password@host/name``; hidden in reprs and errors."""
@@ -111,6 +115,21 @@ class DatabaseConfig(BaseModel):
111
115
  return self
112
116
 
113
117
 
118
+ def _check_port(parts: SplitResult) -> None:
119
+ """Refuse a URL whose port is not a number, without quoting it.
120
+
121
+ A ``/`` in the password ends the host early, so the port Python reads is
122
+ part of the password; its own error would quote it.
123
+ """
124
+ try:
125
+ parts.port # noqa: B018 - reading it parses it.
126
+ except ValueError:
127
+ raise ValueError(
128
+ "the URL's port is not a number; percent-encode / ? # @ and : in "
129
+ "the user name and password"
130
+ ) from None
131
+
132
+
114
133
  class LedgerConfig(BaseModel):
115
134
  """One ``LEDGERS`` entry: write once, read many, verified on the client.
116
135
 
@@ -118,7 +137,7 @@ class LedgerConfig(BaseModel):
118
137
  migrated and never take part in a unit of work.
119
138
  """
120
139
 
121
- model_config = ConfigDict(frozen=True, extra="forbid")
140
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
122
141
 
123
142
  URL: SecretStr
124
143
  """``immudb://user:password@host:3322/database``, or ``memory://`` for tests."""
@@ -135,6 +154,7 @@ class LedgerConfig(BaseModel):
135
154
  parts = urlsplit(self.URL.get_secret_value())
136
155
  if parts.scheme not in LEDGER_SCHEMES:
137
156
  raise ValueError("a ledger URL starts with immudb:// or memory://")
157
+ _check_port(parts)
138
158
  if parts.scheme == "immudb" and not parts.hostname:
139
159
  raise ValueError("an immudb URL needs a host: immudb://user:pw@host/db")
140
160
  return self
@@ -152,7 +172,7 @@ class ShardConfig(BaseModel):
152
172
  to one database. Moving buckets between databases is how a group grows.
153
173
  """
154
174
 
155
- model_config = ConfigDict(frozen=True, extra="forbid")
175
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
156
176
 
157
177
  DATABASES: tuple[str, ...] = Field(min_length=1)
158
178
  """The group's SQL aliases; ``makemigrations`` compares against the first."""
@@ -224,7 +244,7 @@ class CacheConfig(BaseModel):
224
244
  ``get_or_set`` and ``@cached`` compute values instead (see ADR 0012).
225
245
  """
226
246
 
227
- model_config = ConfigDict(frozen=True, extra="forbid")
247
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
228
248
 
229
249
  URL: SecretStr = SecretStr("memory://")
230
250
  """``memory://`` (this process), ``redis://``/``rediss://`` (Redis, Valkey), ``dummy://``."""
@@ -264,6 +284,7 @@ class CacheConfig(BaseModel):
264
284
  raise ValueError(
265
285
  "a cache URL starts with memory://, redis://, rediss://, or dummy://"
266
286
  )
287
+ _check_port(parts)
267
288
  if parts.scheme in REDIS_SCHEMES:
268
289
  if not parts.hostname:
269
290
  raise ValueError("a Redis URL needs a host: redis://host:6379/0")
@@ -318,7 +339,7 @@ class BrokerConfig(BaseModel):
318
339
  never drops them (see ADR 0014).
319
340
  """
320
341
 
321
- model_config = ConfigDict(frozen=True, extra="forbid")
342
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
322
343
 
323
344
  URL: SecretStr
324
345
  """``amqps://user:password@host:5671/vhost`` (RabbitMQ), or ``memory://``."""
@@ -340,6 +361,7 @@ class BrokerConfig(BaseModel):
340
361
  parts = urlsplit(self.URL.get_secret_value())
341
362
  if parts.scheme not in BROKER_SCHEMES:
342
363
  raise ValueError("a broker URL starts with amqp://, amqps://, or memory://")
364
+ _check_port(parts)
343
365
  if parts.scheme != "memory":
344
366
  if not parts.hostname:
345
367
  raise ValueError("an AMQP URL needs a host: amqps://user:pw@host/vhost")
@@ -380,7 +402,7 @@ class BrokerConfig(BaseModel):
380
402
  class Settings(BaseModel):
381
403
  """Validated, read-only settings; extra names are checked at ``setup()``."""
382
404
 
383
- model_config = ConfigDict(frozen=True, extra="allow")
405
+ model_config = ConfigDict(frozen=True, extra="allow", allow_inf_nan=False)
384
406
 
385
407
  SETTINGS_MODULE: str = ""
386
408
  """Set by the loader to the module that produced these settings."""
@@ -466,6 +488,23 @@ class Settings(BaseModel):
466
488
  )
467
489
  CORS_ALLOW_CREDENTIALS: bool = False
468
490
 
491
+ def __repr_args__(self) -> Iterator[tuple[str | None, Any]]:
492
+ """Show declared settings; domain settings, which may hold keys, hidden."""
493
+ domain = self.model_extra or {}
494
+ for name, value in super().__repr_args__():
495
+ yield name, HIDDEN if name in domain else value
496
+
497
+ @model_serializer(mode="wrap")
498
+ def _hide_domain_settings(
499
+ self, handler: SerializerFunctionWrapHandler
500
+ ) -> dict[str, Any]:
501
+ """Dump domain settings hidden: they are checked, and typed, elsewhere."""
502
+ dumped: dict[str, Any] = handler(self)
503
+ return {
504
+ name: HIDDEN if name in (self.model_extra or {}) else value
505
+ for name, value in dumped.items()
506
+ }
507
+
469
508
  @model_validator(mode="after")
470
509
  def check_consistency(self) -> Self:
471
510
  """Reject combinations that are unsafe or would fail later at runtime."""
@@ -534,6 +573,12 @@ class Settings(BaseModel):
534
573
  f"Requests are not rate limited; set RATE_LIMIT and list "
535
574
  f"{RATE_LIMIT_MIDDLEWARE} in MIDDLEWARE."
536
575
  )
576
+ elif not self.TRUSTED_PROXIES:
577
+ warnings.append(
578
+ "TRUSTED_PROXIES is empty, so callers are rate limited by the "
579
+ "address they connect from: behind a load balancer or proxy, "
580
+ "they all share its count. List its networks in TRUSTED_PROXIES."
581
+ )
537
582
  return warnings
538
583
 
539
584
  def _check_deployment(self) -> None:
@@ -543,6 +588,11 @@ class Settings(BaseModel):
543
588
  raise ValueError(f"DEBUG must be False {where}")
544
589
  if "*" in self.ALLOWED_HOSTS:
545
590
  raise ValueError(f"ALLOWED_HOSTS must list real host names {where}")
591
+ if any(network.prefixlen == 0 for network in self.TRUSTED_PROXIES):
592
+ raise ValueError(
593
+ f"TRUSTED_PROXIES must list the proxies' networks, not every "
594
+ f"address {where}: any caller could choose its own address"
595
+ )
546
596
  rules: dict[str, Callable[[DatabaseConfig], bool]] = {
547
597
  "ECHO logs every statement; disable it": lambda db: db.ECHO,
548
598
  "LOG_PARAMETERS exposes bound values; disable it": lambda db: (
@@ -8,6 +8,10 @@ from collections.abc import Iterator
8
8
  from contextlib import contextmanager
9
9
  from contextvars import ContextVar
10
10
  from dataclasses import dataclass
11
+ from typing import Final
12
+
13
+ SYSTEM_ACTOR: Final = "system"
14
+ """The actor of work no caller asked for: periodic tasks, commands' messages."""
11
15
 
12
16
 
13
17
  @dataclass(frozen=True, slots=True)
@@ -15,7 +19,8 @@ class RequestContext:
15
19
  """Facts about the request or job being served; no personal data.
16
20
 
17
21
  ``actor`` names who is acting. Authentication sets it; None means an
18
- anonymous caller, and background work carries the actor that queued it.
22
+ anonymous caller, and background work carries the actor that queued it,
23
+ or ``SYSTEM_ACTOR`` when no request did.
19
24
  """
20
25
 
21
26
  request_id: str
@@ -52,6 +52,8 @@ def _summary(report: Report) -> str:
52
52
  ):
53
53
  if found:
54
54
  lines.append(f" {name}: {', '.join(str(each) for each in found)}")
55
+ if report.unanchored:
56
+ lines.append(f" seals left unanchored: {report.unanchored}")
55
57
  if report.unsealed:
56
58
  lines.append(f" unsealed rows: {report.unsealed}")
57
59
  return "\n".join(lines)
@@ -52,7 +52,8 @@ def pseudonym(key: bytes, field: str, value: object) -> str:
52
52
  """``value``, the ``field`` of a subject, as a pseudonym under their ``key``.
53
53
 
54
54
  The field's name is part of it, so equal values in different fields do
55
- not give away that they are equal.
55
+ not give away that they are equal. Lone surrogates in strings are
56
+ encoded as they are, so every value has a pseudonym.
56
57
  """
57
58
  data = json.dumps(
58
59
  to_jsonable_python(value),
@@ -60,7 +61,8 @@ def pseudonym(key: bytes, field: str, value: object) -> str:
60
61
  separators=(",", ":"),
61
62
  ensure_ascii=False,
62
63
  )
63
- digest = hmac.new(key, f"{field}\0{data}".encode(), hashlib.sha256).hexdigest()
64
+ message = f"{field}\0{data}".encode("utf-8", "surrogatepass")
65
+ digest = hmac.new(key, message, hashlib.sha256).hexdigest()
64
66
  return f"{PREFIX}{digest[:DIGITS]}"
65
67
 
66
68
 
@@ -1,7 +1,8 @@
1
1
  """Building and writing audit rows.
2
2
 
3
3
  Who acted comes from the request context: the authenticated actor,
4
- ``anonymous`` for unauthenticated requests, ``system`` outside requests.
4
+ ``anonymous`` for unauthenticated requests, ``system`` outside requests and
5
+ in work no request queued (periodic tasks).
5
6
  Never put secrets in event fields or ``details``: they are kept for good.
6
7
 
7
8
  Personal values are written as pseudonyms (see ``personal``): the client's
@@ -22,7 +23,7 @@ from sqlalchemy import insert
22
23
  from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
23
24
 
24
25
  from domainkit.apps import registry
25
- from domainkit.context import current_request
26
+ from domainkit.context import SYSTEM_ACTOR, current_request
26
27
  from domainkit.db import connections
27
28
  from domainkit.ddd import DomainEvent
28
29
  from domainkit.ddd.personal import personal_fields
@@ -47,7 +48,7 @@ def _actor() -> str:
47
48
  """Return who is acting in the current context."""
48
49
  request = current_request()
49
50
  if request is None:
50
- return "system"
51
+ return SYSTEM_ACTOR
51
52
  return request.actor or "anonymous"
52
53
 
53
54
 
@@ -110,8 +111,11 @@ def audit_row(
110
111
  """
111
112
  request = current_request()
112
113
  occurred_at = occurred_at or datetime.now(UTC)
113
- caller = (request.actor if request else None) or anonymous_subject(
114
- occurred_at.astimezone(UTC).date()
114
+ actor = request.actor if request else None
115
+ caller = (
116
+ actor
117
+ if actor not in {None, SYSTEM_ACTOR}
118
+ else anonymous_subject(occurred_at.astimezone(UTC).date())
115
119
  )
116
120
  row = {
117
121
  "id": uuid.uuid4(),
@@ -10,7 +10,8 @@ verifies what it returns: a chain rewritten after a head was anchored no
10
10
  longer matches it.
11
11
 
12
12
  ``verify`` reports changed and missing rows, links that do not follow,
13
- anchors the chain no longer matches, and rows left unsealed.
13
+ anchors the chain no longer matches, seals left unanchored, and rows left
14
+ unsealed.
14
15
 
15
16
  Rows are sealed after they commit, by the worker every ``AUDIT.SEAL_EVERY``
16
17
  or by ``auditseal``, so a row can be rewritten unnoticed until it is sealed
@@ -114,6 +115,9 @@ class Report:
114
115
  """Seals whose number or link does not follow the one before."""
115
116
  rewritten: list[int] = field(default_factory=list)
116
117
  """Anchored heads the chain no longer has."""
118
+ unanchored: int = 0
119
+ """Seals after the newest anchor, sealed more than ``ANCHOR_EVERY`` and
120
+ ``LOOKBACK`` ago: anchoring stopped (checked with a ledger only)."""
117
121
  unsealed: int = 0
118
122
  """Rows older than ``LOOKBACK`` without a seal."""
119
123
 
@@ -121,7 +125,7 @@ class Report:
121
125
  def intact(self) -> bool:
122
126
  """Whether nothing was found wrong."""
123
127
  problems = (self.changed, self.missing, self.broken, self.rewritten)
124
- return not (any(problems) or self.unsealed)
128
+ return not (any(problems) or self.unanchored or self.unsealed)
125
129
 
126
130
 
127
131
  def digest(row: Mapping[Any, Any]) -> str:
@@ -163,7 +167,8 @@ async def anchor(
163
167
  ) -> Anchor | None:
164
168
  """Write ``database``'s head to ``ledger``; None when there is nothing new.
165
169
 
166
- A head is anchored at most once per ``every``, and never twice.
170
+ A head is anchored at most once per ``every``, and never twice. An anchor
171
+ dated after ``now`` (written by a clock running ahead) holds none back.
167
172
  """
168
173
  now = now or datetime.now(UTC)
169
174
  async with connections.engine(database).connect() as connection:
@@ -175,7 +180,7 @@ async def anchor(
175
180
  except LedgerKeyNotFound:
176
181
  latest = None
177
182
  if latest is not None and (
178
- latest.seq == head.seq or now - latest.anchored_at < every
183
+ latest.seq == head.seq or timedelta(0) <= now - latest.anchored_at < every
179
184
  ):
180
185
  return None
181
186
  written = Anchor(head.seq, head.link, now)
@@ -199,12 +204,27 @@ async def seal_all() -> dict[str, tuple[int, Anchor | None]]:
199
204
 
200
205
 
201
206
  async def verify(
202
- database: str, ledger: Ledger | None = None, *, now: datetime | None = None
207
+ database: str,
208
+ ledger: Ledger | None = None,
209
+ *,
210
+ now: datetime | None = None,
211
+ every: timedelta | None = None,
203
212
  ) -> Report:
204
- """Check ``database``'s chain against its rows, and against ``ledger``."""
213
+ """Check ``database``'s chain against its rows, and against ``ledger``.
214
+
215
+ With a ledger, seals sealed more than ``every`` (default:
216
+ ``AUDIT.ANCHOR_EVERY``) and ``LOOKBACK`` ago must be anchored.
217
+ """
218
+ now = now or datetime.now(UTC)
205
219
  report = Report(database)
206
220
  anchored = await _anchors(database, ledger) if ledger is not None else {}
207
221
  report.anchors = len(anchored)
222
+ newest_anchored = max(anchored, default=0)
223
+ overdue = None # Sealed before this, a seal must be anchored.
224
+ if ledger is not None:
225
+ if every is None:
226
+ every = registry.settings(AuditSettings).ANCHOR_EVERY
227
+ overdue = now - every - LOOKBACK
208
228
  previous = Head(0, GENESIS)
209
229
  query = (
210
230
  select(audit_seals, *(audit_log.c[name] for name in COLUMNS))
@@ -225,16 +245,23 @@ async def verify(
225
245
  report.changed.append(seal_row["entry_id"])
226
246
  if anchored.pop(seal_row["seq"], seal_row["link"]) != seal_row["link"]:
227
247
  report.rewritten.append(seal_row["seq"])
248
+ if (
249
+ overdue is not None
250
+ and seal_row["seq"] > newest_anchored
251
+ and seal_row["sealed_at"] < overdue
252
+ ):
253
+ report.unanchored += 1
228
254
  previous = Head(seal_row["seq"], seal_row["link"])
229
255
  report.rewritten.extend(sorted(anchored)) # Anchored seals now gone.
230
- report.unsealed = await _unsealed(connection, now or datetime.now(UTC))
256
+ report.unsealed = await _unsealed(connection, now)
231
257
  return report
232
258
 
233
259
 
234
260
  async def _seal_batch(connection: AsyncConnection, batch: int) -> int:
235
261
  """Seal up to ``batch`` rows after the head, oldest first."""
236
262
  head = await _head(connection)
237
- horizon = await _horizon(connection, head)
263
+ sealed_at = datetime.now(UTC)
264
+ horizon = await _horizon(connection, head, sealed_at)
238
265
  unsealed = (
239
266
  select(*(audit_log.c[name] for name in COLUMNS))
240
267
  .outerjoin(audit_seals, audit_seals.c.entry_id == audit_log.c.id)
@@ -246,7 +273,6 @@ async def _seal_batch(connection: AsyncConnection, batch: int) -> int:
246
273
  unsealed = unsealed.where(audit_log.c.occurred_at >= horizon)
247
274
  rows = (await connection.execute(unsealed)).mappings().all()
248
275
  previous = head or Head(0, GENESIS)
249
- sealed_at = datetime.now(UTC)
250
276
  seals = []
251
277
  for row in rows:
252
278
  row_digest = digest(row)
@@ -277,8 +303,14 @@ async def _head(connection: AsyncConnection) -> Head | None:
277
303
  return None if found is None else Head(found.seq, found.link)
278
304
 
279
305
 
280
- async def _horizon(connection: AsyncConnection, head: Head | None) -> datetime | None:
281
- """Where to look for unsealed rows: ``LOOKBACK`` before the head's row."""
306
+ async def _horizon(
307
+ connection: AsyncConnection, head: Head | None, now: datetime
308
+ ) -> datetime | None:
309
+ """Where to look for unsealed rows: ``LOOKBACK`` before the head's row.
310
+
311
+ A head's row dated after ``now`` (a clock running ahead) counts as
312
+ ``now``, so it cannot leave the rows written before it unsealable.
313
+ """
282
314
  if head is None:
283
315
  return None
284
316
  newest = await connection.scalar(
@@ -286,7 +318,7 @@ async def _horizon(connection: AsyncConnection, head: Head | None) -> datetime |
286
318
  .join(audit_seals, audit_seals.c.entry_id == audit_log.c.id)
287
319
  .where(audit_seals.c.seq == head.seq)
288
320
  )
289
- return None if newest is None else newest - LOOKBACK
321
+ return None if newest is None else min(newest, now) - LOOKBACK
290
322
 
291
323
 
292
324
  async def _unsealed(connection: AsyncConnection, now: datetime) -> int:
@@ -327,7 +359,12 @@ def _plain(value: object) -> object:
327
359
 
328
360
 
329
361
  def _canonical(document: object) -> bytes:
330
- """``document`` as compact JSON with sorted keys."""
362
+ """``document`` as compact JSON with sorted keys.
363
+
364
+ Strings can hold lone surrogates (JSON can escape half a surrogate
365
+ pair); they are encoded as they are, so every row has a digest, and the
366
+ bytes of every other string stay plain UTF-8.
367
+ """
331
368
  return json.dumps(
332
369
  document, sort_keys=True, separators=(",", ":"), ensure_ascii=False
333
- ).encode()
370
+ ).encode("utf-8", "surrogatepass")
@@ -10,7 +10,7 @@ from domainkit.conf import DEFAULT_DB_ALIAS
10
10
  class AuditSettings(BaseModel):
11
11
  """What to record; every key is optional."""
12
12
 
13
- model_config = ConfigDict(frozen=True, extra="forbid")
13
+ model_config = ConfigDict(frozen=True, extra="forbid", allow_inf_nan=False)
14
14
 
15
15
  DATABASE: str = DEFAULT_DB_ALIAS
16
16
  """Alias holding the audit trail; event audits need units of work on it."""
@@ -46,6 +46,7 @@ from .access import (
46
46
  Require,
47
47
  check_object_permissions,
48
48
  current_user,
49
+ mark_public,
49
50
  open_routes,
50
51
  )
51
52
  from .authentication import (
@@ -165,6 +166,7 @@ __all__ = [
165
166
  "has_perm",
166
167
  "has_perms",
167
168
  "make_password",
169
+ "mark_public",
168
170
  "open_routes",
169
171
  "password_help_texts",
170
172
  "validate_password",