regstack 0.2.3__tar.gz → 0.2.5__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 (177) hide show
  1. {regstack-0.2.3 → regstack-0.2.5}/CHANGELOG.md +36 -0
  2. {regstack-0.2.3 → regstack-0.2.5}/PKG-INFO +1 -1
  3. {regstack-0.2.3 → regstack-0.2.5}/docs/architecture.md +0 -1
  4. {regstack-0.2.3 → regstack-0.2.5}/docs/changelog.md +64 -0
  5. {regstack-0.2.3 → regstack-0.2.5}/pyproject.toml +23 -1
  6. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/app.py +0 -8
  7. regstack-0.2.5/src/regstack/backends/mongo/repositories/__init__.py +13 -0
  8. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -3
  9. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -3
  10. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/user_repo.py +0 -3
  11. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/migrations/__init__.py +18 -12
  12. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/doctor.py +2 -2
  13. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/_objectid.py +0 -8
  14. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/login_attempt.py +2 -2
  15. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/mfa_code.py +2 -2
  16. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/pending_registration.py +2 -2
  17. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/user.py +2 -2
  18. regstack-0.2.5/src/regstack/version.py +1 -0
  19. {regstack-0.2.3 → regstack-0.2.5}/tasks.py +42 -0
  20. regstack-0.2.5/tests/unit/test_cli_doctor.py +164 -0
  21. regstack-0.2.5/tests/unit/test_cli_init.py +150 -0
  22. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_mfa_code_repo.py +2 -1
  23. {regstack-0.2.3 → regstack-0.2.5}/uv.lock +1 -1
  24. regstack-0.2.3/src/regstack/backends/mongo/repositories/__init__.py +0 -27
  25. regstack-0.2.3/src/regstack/version.py +0 -1
  26. {regstack-0.2.3 → regstack-0.2.5}/.github/workflows/publish.yml +0 -0
  27. {regstack-0.2.3 → regstack-0.2.5}/.github/workflows/test.yml +0 -0
  28. {regstack-0.2.3 → regstack-0.2.5}/.gitignore +0 -0
  29. {regstack-0.2.3 → regstack-0.2.5}/.python-version +0 -0
  30. {regstack-0.2.3 → regstack-0.2.5}/.readthedocs.yaml +0 -0
  31. {regstack-0.2.3 → regstack-0.2.5}/CLAUDE.md +0 -0
  32. {regstack-0.2.3 → regstack-0.2.5}/LICENSE +0 -0
  33. {regstack-0.2.3 → regstack-0.2.5}/NOTICE +0 -0
  34. {regstack-0.2.3 → regstack-0.2.5}/README.md +0 -0
  35. {regstack-0.2.3 → regstack-0.2.5}/SECURITY.md +0 -0
  36. {regstack-0.2.3 → regstack-0.2.5}/docs/_static/.gitkeep +0 -0
  37. {regstack-0.2.3 → regstack-0.2.5}/docs/_templates/.gitkeep +0 -0
  38. {regstack-0.2.3 → regstack-0.2.5}/docs/api.md +0 -0
  39. {regstack-0.2.3 → regstack-0.2.5}/docs/cli.md +0 -0
  40. {regstack-0.2.3 → regstack-0.2.5}/docs/conf.py +0 -0
  41. {regstack-0.2.3 → regstack-0.2.5}/docs/configuration.md +0 -0
  42. {regstack-0.2.3 → regstack-0.2.5}/docs/embedding.md +0 -0
  43. {regstack-0.2.3 → regstack-0.2.5}/docs/index.md +0 -0
  44. {regstack-0.2.3 → regstack-0.2.5}/docs/quickstart.md +0 -0
  45. {regstack-0.2.3 → regstack-0.2.5}/docs/security.md +0 -0
  46. {regstack-0.2.3 → regstack-0.2.5}/docs/theming.md +0 -0
  47. {regstack-0.2.3 → regstack-0.2.5}/examples/_common/__init__.py +0 -0
  48. {regstack-0.2.3 → regstack-0.2.5}/examples/_common/app.py +0 -0
  49. {regstack-0.2.3 → regstack-0.2.5}/examples/mongo/README.md +0 -0
  50. {regstack-0.2.3 → regstack-0.2.5}/examples/mongo/branding/theme.css +0 -0
  51. {regstack-0.2.3 → regstack-0.2.5}/examples/mongo/main.py +0 -0
  52. {regstack-0.2.3 → regstack-0.2.5}/examples/mongo/regstack.toml +0 -0
  53. {regstack-0.2.3 → regstack-0.2.5}/examples/postgres/README.md +0 -0
  54. {regstack-0.2.3 → regstack-0.2.5}/examples/postgres/main.py +0 -0
  55. {regstack-0.2.3 → regstack-0.2.5}/examples/postgres/regstack.toml +0 -0
  56. {regstack-0.2.3 → regstack-0.2.5}/examples/sqlite/README.md +0 -0
  57. {regstack-0.2.3 → regstack-0.2.5}/examples/sqlite/main.py +0 -0
  58. {regstack-0.2.3 → regstack-0.2.5}/examples/sqlite/regstack.toml +0 -0
  59. {regstack-0.2.3 → regstack-0.2.5}/regstack.toml.example +0 -0
  60. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/__init__.py +0 -0
  61. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/__init__.py +0 -0
  62. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/clock.py +0 -0
  63. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/dependencies.py +0 -0
  64. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/jwt.py +0 -0
  65. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/lockout.py +0 -0
  66. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/mfa.py +0 -0
  67. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/password.py +0 -0
  68. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/auth/tokens.py +0 -0
  69. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/__init__.py +0 -0
  70. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/base.py +0 -0
  71. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/factory.py +0 -0
  72. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/__init__.py +0 -0
  73. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/backend.py +0 -0
  74. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/client.py +0 -0
  75. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/indexes.py +0 -0
  76. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  77. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  78. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/protocols.py +0 -0
  79. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/__init__.py +0 -0
  80. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/backend.py +0 -0
  81. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/migrations/env.py +0 -0
  82. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  83. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  84. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  85. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  86. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  87. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  88. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  89. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  90. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/schema.py +0 -0
  91. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/backends/sql/types.py +0 -0
  92. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/__init__.py +0 -0
  93. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/__main__.py +0 -0
  94. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/_runtime.py +0 -0
  95. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/admin.py +0 -0
  96. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/init.py +0 -0
  97. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/cli/migrate.py +0 -0
  98. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/config/__init__.py +0 -0
  99. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/config/loader.py +0 -0
  100. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/config/schema.py +0 -0
  101. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/config/secrets.py +0 -0
  102. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/__init__.py +0 -0
  103. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/base.py +0 -0
  104. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/composer.py +0 -0
  105. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/console.py +0 -0
  106. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/factory.py +0 -0
  107. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/ses.py +0 -0
  108. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/smtp.py +0 -0
  109. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/email_change.html +0 -0
  110. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/email_change.subject.txt +0 -0
  111. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/email_change.txt +0 -0
  112. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/password_reset.html +0 -0
  113. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  114. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/password_reset.txt +0 -0
  115. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  116. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  117. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/verification.html +0 -0
  118. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/verification.subject.txt +0 -0
  119. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/email/templates/verification.txt +0 -0
  120. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/hooks/__init__.py +0 -0
  121. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/hooks/events.py +0 -0
  122. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/models/__init__.py +0 -0
  123. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/__init__.py +0 -0
  124. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/_schemas.py +0 -0
  125. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/account.py +0 -0
  126. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/admin.py +0 -0
  127. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/login.py +0 -0
  128. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/logout.py +0 -0
  129. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/password.py +0 -0
  130. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/phone.py +0 -0
  131. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/register.py +0 -0
  132. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/routers/verify.py +0 -0
  133. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/__init__.py +0 -0
  134. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/base.py +0 -0
  135. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/factory.py +0 -0
  136. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/null.py +0 -0
  137. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/sns.py +0 -0
  138. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/sms/twilio.py +0 -0
  139. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/__init__.py +0 -0
  140. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/pages.py +0 -0
  141. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/static/css/core.css +0 -0
  142. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/static/css/theme.css +0 -0
  143. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/static/js/regstack.js +0 -0
  144. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  145. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/forgot.html +0 -0
  146. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/login.html +0 -0
  147. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/me.html +0 -0
  148. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  149. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/register.html +0 -0
  150. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/reset.html +0 -0
  151. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/auth/verify.html +0 -0
  152. {regstack-0.2.3 → regstack-0.2.5}/src/regstack/ui/templates/base.html +0 -0
  153. {regstack-0.2.3 → regstack-0.2.5}/tests/__init__.py +0 -0
  154. {regstack-0.2.3 → regstack-0.2.5}/tests/conftest.py +0 -0
  155. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/__init__.py +0 -0
  156. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_account_management.py +0 -0
  157. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_admin_router.py +0 -0
  158. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_happy_path.py +0 -0
  159. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_indexes.py +0 -0
  160. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_login_lockout.py +0 -0
  161. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_mfa.py +0 -0
  162. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_password_reset.py +0 -0
  163. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_sql_migrations.py +0 -0
  164. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_ui_router.py +0 -0
  165. {regstack-0.2.3 → regstack-0.2.5}/tests/integration/test_verification.py +0 -0
  166. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/__init__.py +0 -0
  167. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_base_install_imports.py +0 -0
  168. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_cli.py +0 -0
  169. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_config_loader.py +0 -0
  170. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_jwt.py +0 -0
  171. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_lockout.py +0 -0
  172. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_mail_composer.py +0 -0
  173. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_password.py +0 -0
  174. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_ses_backend.py +0 -0
  175. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_sms.py +0 -0
  176. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_smtp_backend.py +0 -0
  177. {regstack-0.2.3 → regstack-0.2.5}/tests/unit/test_ui_env.py +0 -0
@@ -5,6 +5,42 @@ authoritative copy lives at
5
5
  [`docs/changelog.md`](docs/changelog.md) and is rendered into the
6
6
  Sphinx docs.
7
7
 
8
+ ## 0.2.5 — 2026-04-28
9
+
10
+ Bug fix + tooling.
11
+
12
+ - **Fix:** `regstack doctor` against a SQL backend crashed with
13
+ `asyncio.run() cannot be called from a running event loop`. The
14
+ schema check called `regstack.backends.sql.migrations.current()`,
15
+ which used `asyncio.run()` internally — invalid inside doctor's own
16
+ `asyncio.run`. Added `current_async()` and switched the doctor
17
+ command to use it. Sync `current()` is preserved for the migrate
18
+ CLI.
19
+ - **New:** `inv coverage [--no-html] [--fail-under=N]` runs the full
20
+ three-backend matrix under coverage and writes term + HTML reports.
21
+ Branch coverage is on by default.
22
+ - Test coverage uplift on the CLI: `cli/init.py` 14% → 88%,
23
+ `cli/doctor.py` 61% → 87%. Total: **85% → 87.1%**.
24
+
25
+ ## 0.2.4 — 2026-04-28
26
+
27
+ **Breaking** — back-compat shims removed:
28
+
29
+ - `RegStack.install_indexes()` (alias for `install_schema()`).
30
+ - `ObjectIdStr` alias for `IdStr` in `regstack.models._objectid`.
31
+ - Re-exports of `UserAlreadyExistsError`,
32
+ `PendingAlreadyExistsError`, `MfaVerifyOutcome`, and
33
+ `MfaVerifyResult` from `regstack.backends.mongo.repositories.*`.
34
+ Their canonical home is `regstack.backends.protocols`.
35
+
36
+ If you import any of these from the old paths, switch to:
37
+ - `RegStack.install_schema()`
38
+ - `from regstack.models._objectid import IdStr`
39
+ - `from regstack.backends.protocols import UserAlreadyExistsError`
40
+ (and friends).
41
+
42
+ The internal mongo `install_indexes(db, config)` function is unchanged.
43
+
8
44
  ## 0.2.3 — 2026-04-28
9
45
 
10
46
  Docs-only release. Restructured the API reference around the current
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/regstack
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/regstack
@@ -72,7 +72,6 @@ The façade exposes:
72
72
  - `backend` — the active `regstack.backends.base.Backend`.
73
73
  - `install_schema()` — install indexes (Mongo) or run
74
74
  [Alembic](https://alembic.sqlalchemy.org/) migrations (SQL).
75
- `install_indexes()` is kept as a back-compat alias.
76
75
  - `aclose()` — tear down the backend's connection pool.
77
76
  - `bootstrap_admin(email, password)`,
78
77
  `add_template_dir(path)`, `set_email_backend(...)`,
@@ -3,6 +3,70 @@
3
3
  All notable changes to this project are documented here. Versions follow
4
4
  [Semantic Versioning](https://semver.org/) once `1.0.0` ships.
5
5
 
6
+ ## 0.2.5 — 2026-04-28
7
+
8
+ **Bug fix + tooling.**
9
+
10
+ ### Fixed
11
+
12
+ - ``regstack doctor`` against a SQL backend crashed with
13
+ ``asyncio.run() cannot be called from a running event loop``. The
14
+ schema check called
15
+ ``regstack.backends.sql.migrations.current()``, which used
16
+ ``asyncio.run()`` internally — invalid inside doctor's own
17
+ ``asyncio.run``. Added ``current_async()`` and switched the doctor
18
+ command to use it. Sync ``current()`` is preserved for the migrate
19
+ CLI (which runs outside an event loop).
20
+
21
+ ### Added
22
+
23
+ - ``inv coverage [--no-html] [--fail-under=N]`` — runs the full
24
+ three-backend matrix under coverage, combines per-pytest-xdist-worker
25
+ ``.coverage`` files, prints the term-with-missing report, and writes
26
+ ``htmlcov/``. Branch coverage is on by default.
27
+ - ``[tool.coverage.*]`` config in ``pyproject.toml``.
28
+ - ``tests/unit/test_cli_init.py`` — six tests driving the
29
+ ``regstack init`` wizard via ``CliRunner(input=...)``. Lifts
30
+ ``cli/init.py`` from 14% → 88%.
31
+ - ``tests/unit/test_cli_doctor.py`` — four tests for the SQLite
32
+ ``regstack doctor`` paths. Lifts ``cli/doctor.py`` from 61% → 87%.
33
+
34
+ Total line coverage on the full backend matrix: **85% → 87.1%**
35
+ (branch coverage is also newly enabled).
36
+
37
+ ## 0.2.4 — 2026-04-28
38
+
39
+ **Breaking** — every back-compat shim left over from the
40
+ multi-backend refactor has been removed.
41
+
42
+ ### Removed
43
+
44
+ - `RegStack.install_indexes()` — the 0.1.x alias for
45
+ `install_schema()`. Call `install_schema()`.
46
+ - `ObjectIdStr` alias for `IdStr` in `regstack.models._objectid`.
47
+ Import `IdStr` directly.
48
+ - `__all__`-based re-exports of `UserAlreadyExistsError`,
49
+ `PendingAlreadyExistsError`, `MfaVerifyOutcome`, and
50
+ `MfaVerifyResult` from `regstack.backends.mongo.repositories.*` and
51
+ the package `__init__`. Their canonical home is
52
+ `regstack.backends.protocols`; that's where every consumer in the
53
+ package itself already imports them.
54
+
55
+ ### Migration
56
+
57
+ | Old | New |
58
+ |----------------------------------------------------------------------------------|----------------------------------------------------------------|
59
+ | `await regstack.install_indexes()` | `await regstack.install_schema()` |
60
+ | `from regstack.models._objectid import ObjectIdStr` | `from regstack.models._objectid import IdStr` |
61
+ | `from regstack.backends.mongo.repositories.user_repo import UserAlreadyExistsError` | `from regstack.backends.protocols import UserAlreadyExistsError` |
62
+ | `from regstack.backends.mongo.repositories.pending_repo import PendingAlreadyExistsError` | `from regstack.backends.protocols import PendingAlreadyExistsError` |
63
+ | `from regstack.backends.mongo.repositories.mfa_code_repo import MfaVerifyOutcome, MfaVerifyResult` | `from regstack.backends.protocols import MfaVerifyOutcome, MfaVerifyResult` |
64
+
65
+ The internal Mongo helper
66
+ `regstack.backends.mongo.indexes.install_indexes(db, config)` is
67
+ unchanged — that's the function `MongoBackend.install_schema` calls
68
+ to actually create the indexes.
69
+
6
70
  ## 0.2.3 — 2026-04-28
7
71
 
8
72
  **Docs-only release.** API reference rewritten around the current
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -119,3 +119,25 @@ python_version = "3.11"
119
119
  strict = true
120
120
  warn_unreachable = true
121
121
  plugins = ["pydantic.mypy"]
122
+
123
+ [tool.coverage.run]
124
+ source = ["src/regstack"]
125
+ branch = true
126
+ parallel = true
127
+ # `concurrency = ["thread"]` is implicit; pytest-xdist workers each write
128
+ # their own .coverage.<host>.<pid>.<token> file and `coverage combine`
129
+ # folds them together — see the `coverage` invoke task.
130
+
131
+ [tool.coverage.report]
132
+ show_missing = true
133
+ skip_covered = false
134
+ precision = 1
135
+ exclude_lines = [
136
+ "pragma: no cover",
137
+ "if TYPE_CHECKING:",
138
+ "raise NotImplementedError",
139
+ "\\.\\.\\.$", # body-less Protocol methods
140
+ ]
141
+
142
+ [tool.coverage.html]
143
+ directory = "htmlcov"
@@ -213,14 +213,6 @@ class RegStack:
213
213
  """
214
214
  await self.backend.install_schema()
215
215
 
216
- async def install_indexes(self) -> None:
217
- """Backwards-compatible alias for :meth:`install_schema`.
218
-
219
- Kept for the 0.1.x ``install_indexes()`` name. New callers
220
- should use :meth:`install_schema`.
221
- """
222
- await self.install_schema()
223
-
224
216
  async def aclose(self) -> None:
225
217
  """Tear down the backend's connection pool.
226
218
 
@@ -0,0 +1,13 @@
1
+ from regstack.backends.mongo.repositories.blacklist_repo import BlacklistRepo
2
+ from regstack.backends.mongo.repositories.login_attempt_repo import LoginAttemptRepo
3
+ from regstack.backends.mongo.repositories.mfa_code_repo import MfaCodeRepo
4
+ from regstack.backends.mongo.repositories.pending_repo import PendingRepo
5
+ from regstack.backends.mongo.repositories.user_repo import UserRepo
6
+
7
+ __all__ = [
8
+ "BlacklistRepo",
9
+ "LoginAttemptRepo",
10
+ "MfaCodeRepo",
11
+ "PendingRepo",
12
+ "UserRepo",
13
+ ]
@@ -13,9 +13,6 @@ if TYPE_CHECKING:
13
13
  from regstack.auth.clock import Clock
14
14
 
15
15
 
16
- __all__ = ["MfaCodeRepo", "MfaVerifyOutcome", "MfaVerifyResult"]
17
-
18
-
19
16
  class MfaCodeRepo:
20
17
  def __init__(self, db: AsyncDatabase, collection_name: str, *, clock: Clock) -> None:
21
18
  self._collection = db[collection_name]
@@ -13,9 +13,6 @@ if TYPE_CHECKING:
13
13
  from pymongo.asynchronous.database import AsyncDatabase
14
14
 
15
15
 
16
- __all__ = ["PendingAlreadyExistsError", "PendingRepo"]
17
-
18
-
19
16
  class PendingRepo:
20
17
  def __init__(self, db: AsyncDatabase, collection_name: str) -> None:
21
18
  self._collection = db[collection_name]
@@ -14,9 +14,6 @@ if TYPE_CHECKING:
14
14
  from pymongo.asynchronous.database import AsyncDatabase
15
15
 
16
16
 
17
- __all__ = ["UserAlreadyExistsError", "UserRepo"]
18
-
19
-
20
17
  def _bulk_revoke_cutoff(now: datetime) -> datetime:
21
18
  """The cutoff timestamp recorded on the user document. Stored at full
22
19
  microsecond precision; the JWT ``iat`` claim is also emitted as a float
@@ -71,23 +71,29 @@ async def upgrade_async(database_url: str, revision: str = "head") -> None:
71
71
 
72
72
  def current(database_url: str) -> str | None:
73
73
  """Return the current revision recorded in alembic_version, or None
74
- if the table doesn't exist (i.e. fresh DB)."""
75
- engine = create_async_engine(database_url)
76
-
77
- async def _read() -> str | None:
78
- async with engine.connect() as conn:
79
- return await conn.run_sync(_current_sync)
74
+ if the table doesn't exist (i.e. fresh DB).
80
75
 
76
+ Synchronous; do NOT call from inside a running event loop —
77
+ use :func:`current_async` instead.
78
+ """
81
79
  import asyncio
82
80
 
81
+ return asyncio.run(_current_async_impl(database_url))
82
+
83
+
84
+ async def current_async(database_url: str) -> str | None:
85
+ """Async variant of :func:`current` — safe to call from inside an
86
+ already-running event loop (e.g. ``regstack doctor``)."""
87
+ return await _current_async_impl(database_url)
88
+
89
+
90
+ async def _current_async_impl(database_url: str) -> str | None:
91
+ engine = create_async_engine(database_url)
83
92
  try:
84
- return asyncio.run(_read())
93
+ async with engine.connect() as conn:
94
+ return await conn.run_sync(_current_sync)
85
95
  finally:
86
- # ``engine.dispose`` is async too; close on a fresh loop.
87
- async def _close() -> None:
88
- await engine.dispose()
89
-
90
- asyncio.run(_close())
96
+ await engine.dispose()
91
97
 
92
98
 
93
99
  def _current_sync(connection) -> str | None:
@@ -127,10 +127,10 @@ async def _check_schema(config) -> CheckResult:
127
127
  )
128
128
  return CheckResult("schema", True, "core indexes present")
129
129
  # SQL backends: compare alembic_version to the bundled head.
130
- from regstack.backends.sql.migrations import current, head_revision
130
+ from regstack.backends.sql.migrations import current_async, head_revision
131
131
 
132
132
  url = config.database_url.get_secret_value()
133
- live = current(url)
133
+ live = await current_async(url)
134
134
  head = head_revision()
135
135
  if live is None:
136
136
  return CheckResult(
@@ -7,10 +7,6 @@ the backend:
7
7
  - SQL backends use UUID4 hex (32-char).
8
8
  - Hosts with their own ID strategy can substitute anything else as long
9
9
  as it round-trips as a string.
10
-
11
- Historically this was named ``ObjectIdStr`` and validated 24-char hex
12
- only. The rename and relaxed contract landed when the SQL backends
13
- arrived; the old name is kept as an alias for any external imports.
14
10
  """
15
11
 
16
12
  from __future__ import annotations
@@ -49,7 +45,3 @@ class _IdValidator:
49
45
 
50
46
 
51
47
  IdStr = Annotated[str, _IdValidator]
52
-
53
- # Back-compat alias — the contract is now "any non-empty string", so
54
- # anyone reaching for ObjectIdStr keeps the same surface area.
55
- ObjectIdStr = IdStr
@@ -5,7 +5,7 @@ from typing import Any
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, EmailStr, Field
7
7
 
8
- from regstack.models._objectid import ObjectIdStr
8
+ from regstack.models._objectid import IdStr
9
9
 
10
10
 
11
11
  def _utcnow() -> datetime:
@@ -19,7 +19,7 @@ class LoginAttempt(BaseModel):
19
19
 
20
20
  model_config = ConfigDict(populate_by_name=True, extra="allow")
21
21
 
22
- id: ObjectIdStr | None = Field(default=None, alias="_id")
22
+ id: IdStr | None = Field(default=None, alias="_id")
23
23
  email: EmailStr
24
24
  when: datetime = Field(default_factory=_utcnow)
25
25
  ip: str | None = None
@@ -5,7 +5,7 @@ from typing import Any, Literal
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, Field
7
7
 
8
- from regstack.models._objectid import ObjectIdStr
8
+ from regstack.models._objectid import IdStr
9
9
 
10
10
  MfaKind = Literal["phone_setup", "login_mfa"]
11
11
 
@@ -24,7 +24,7 @@ class MfaCode(BaseModel):
24
24
 
25
25
  model_config = ConfigDict(populate_by_name=True, extra="allow")
26
26
 
27
- id: ObjectIdStr | None = Field(default=None, alias="_id")
27
+ id: IdStr | None = Field(default=None, alias="_id")
28
28
  user_id: str
29
29
  kind: MfaKind
30
30
  code_hash: str
@@ -5,7 +5,7 @@ from typing import Any
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, EmailStr, Field
7
7
 
8
- from regstack.models._objectid import ObjectIdStr
8
+ from regstack.models._objectid import IdStr
9
9
 
10
10
 
11
11
  def _utcnow() -> datetime:
@@ -23,7 +23,7 @@ class PendingRegistration(BaseModel):
23
23
 
24
24
  model_config = ConfigDict(populate_by_name=True, extra="allow")
25
25
 
26
- id: ObjectIdStr | None = Field(default=None, alias="_id")
26
+ id: IdStr | None = Field(default=None, alias="_id")
27
27
  email: EmailStr
28
28
  hashed_password: str
29
29
  full_name: str | None = None
@@ -5,7 +5,7 @@ from typing import Annotated, Any
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
7
7
 
8
- from regstack.models._objectid import ObjectIdStr
8
+ from regstack.models._objectid import IdStr
9
9
 
10
10
 
11
11
  def _utcnow() -> datetime:
@@ -25,7 +25,7 @@ class BaseUser(BaseModel):
25
25
 
26
26
  model_config = ConfigDict(populate_by_name=True, extra="allow")
27
27
 
28
- id: ObjectIdStr | None = Field(default=None, alias="_id")
28
+ id: IdStr | None = Field(default=None, alias="_id")
29
29
  email: EmailStr
30
30
  hashed_password: str
31
31
  is_active: bool = True
@@ -0,0 +1 @@
1
+ __version__ = "0.2.5"
@@ -95,6 +95,48 @@ def test_serial(c: Context, k: str = "") -> None:
95
95
  c.run(cmd, pty=True)
96
96
 
97
97
 
98
+ @task
99
+ def coverage(
100
+ c: Context,
101
+ pg_url: str = _DEFAULT_PG_URL,
102
+ html: bool = True,
103
+ fail_under: int = 0,
104
+ ) -> None:
105
+ """Run the full backend matrix under coverage.
106
+
107
+ Combines per-worker .coverage.* files (pytest-xdist runs one
108
+ coverage instance per worker; settings.parallel = true plus
109
+ `coverage combine` glues them back together) and prints the
110
+ line-coverage report. With --html (default), also writes an
111
+ HTML report under ``htmlcov/``.
112
+
113
+ Set ``--fail-under=N`` to make the task exit non-zero when total
114
+ line coverage drops below N percent — useful in CI.
115
+ """
116
+ # Wipe stale coverage state so partial reruns don't leave double-counted
117
+ # data files behind.
118
+ c.run("uv run coverage erase", pty=True, warn=True)
119
+ env = {
120
+ "REGSTACK_TEST_BACKENDS": "sqlite,mongo,postgres",
121
+ "REGSTACK_TEST_POSTGRES_URL": pg_url,
122
+ # pytest-cov picks settings up from [tool.coverage.*] in pyproject.toml.
123
+ "COVERAGE_PROCESS_START": "pyproject.toml",
124
+ }
125
+ c.run(
126
+ "uv run python -m pytest -n auto --cov=src/regstack --cov-report=",
127
+ pty=True,
128
+ env=env,
129
+ )
130
+ c.run("uv run coverage combine", pty=True, warn=True)
131
+ report_cmd = "uv run coverage report"
132
+ if fail_under > 0:
133
+ report_cmd += f" --fail-under={fail_under}"
134
+ c.run(report_cmd, pty=True)
135
+ if html:
136
+ c.run("uv run coverage html", pty=True)
137
+ print("\nHTML report: htmlcov/index.html")
138
+
139
+
98
140
  @task
99
141
  def e2e(c: Context) -> None:
100
142
  """Run Playwright end-to-end suite."""
@@ -0,0 +1,164 @@
1
+ """Tests for the ``regstack doctor`` CLI command.
2
+
3
+ Targets the SQLite branch (no infrastructure required) and exercises:
4
+
5
+ - the JWT-secret quality check (missing / too short / present)
6
+ - the backend ``ping`` path
7
+ - the schema check before and after ``install_schema``
8
+ - the email-factory check
9
+ - the ``--send-test-email`` path against the console backend
10
+ - the ``--check-dns`` path with a domain we expect to fail
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import secrets
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+ from click.testing import CliRunner
22
+
23
+ from regstack.cli.__main__ import cli
24
+ from regstack.cli._runtime import open_regstack
25
+
26
+
27
+ def _write_sqlite_config(
28
+ tmp_path: Path,
29
+ *,
30
+ from_address: str = "noreply@example.com",
31
+ ) -> tuple[Path, Path]:
32
+ sqlite_path = tmp_path / "doctor.db"
33
+ cfg = tmp_path / "regstack.toml"
34
+ cfg.write_text(
35
+ f"""\
36
+ app_name = "doctor-test"
37
+ base_url = "http://localhost:8000"
38
+ database_url = "sqlite+aiosqlite:///{sqlite_path}"
39
+
40
+ jwt_ttl_seconds = 7200
41
+ require_verification = false
42
+ allow_registration = true
43
+
44
+ [email]
45
+ backend = "console"
46
+ from_address = "{from_address}"
47
+ from_name = "doctor-test"
48
+
49
+ [sms]
50
+ backend = "null"
51
+ """
52
+ )
53
+ return cfg, sqlite_path
54
+
55
+
56
+ @pytest.fixture
57
+ def doctor_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple[Path, str]:
58
+ """Strip ambient REGSTACK_* vars, then set a known JWT secret + DB URL.
59
+
60
+ Returns ``(config_path, jwt_secret)``.
61
+ """
62
+ for var in list(os.environ):
63
+ if var.startswith("REGSTACK_"):
64
+ monkeypatch.delenv(var, raising=False)
65
+ secret = secrets.token_urlsafe(64)
66
+ cfg_path, sqlite_path = _write_sqlite_config(tmp_path)
67
+ monkeypatch.setenv("REGSTACK_JWT_SECRET", secret)
68
+ monkeypatch.setenv("REGSTACK_DATABASE_URL", f"sqlite+aiosqlite:///{sqlite_path}")
69
+ return cfg_path, secret
70
+
71
+
72
+ def test_doctor_reports_schema_missing_then_present(
73
+ doctor_env: tuple[Path, str],
74
+ ) -> None:
75
+ """Pre-install: doctor reports schema missing. Post-install: green."""
76
+ cfg_path, _ = doctor_env
77
+ runner = CliRunner()
78
+
79
+ # Pre-install: schema check fails (alembic_version table missing).
80
+ result = runner.invoke(cli, ["doctor", "--config", str(cfg_path)])
81
+ assert result.exit_code >= 1
82
+ assert "schema" in result.output
83
+ assert "alembic_version table missing" in result.output
84
+ assert "jwt secret" in result.output
85
+ assert "email backend" in result.output
86
+
87
+ # Install schema, then re-run.
88
+ async def _install() -> None:
89
+ async with open_regstack(cfg_path) as rs:
90
+ await rs.install_schema()
91
+
92
+ asyncio.run(_install())
93
+
94
+ result = runner.invoke(cli, ["doctor", "--config", str(cfg_path)])
95
+ assert result.exit_code == 0, result.output
96
+ assert "at head" in result.output
97
+
98
+
99
+ def test_doctor_flags_short_jwt_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
100
+ for var in list(os.environ):
101
+ if var.startswith("REGSTACK_"):
102
+ monkeypatch.delenv(var, raising=False)
103
+ cfg_path, sqlite_path = _write_sqlite_config(tmp_path)
104
+ monkeypatch.setenv("REGSTACK_JWT_SECRET", "too-short")
105
+ monkeypatch.setenv("REGSTACK_DATABASE_URL", f"sqlite+aiosqlite:///{sqlite_path}")
106
+
107
+ runner = CliRunner()
108
+ result = runner.invoke(cli, ["doctor", "--config", str(cfg_path)])
109
+ assert result.exit_code >= 1
110
+ assert "jwt secret" in result.output
111
+ assert "too short" in result.output
112
+
113
+
114
+ def test_doctor_send_test_email_via_console(doctor_env: tuple[Path, str]) -> None:
115
+ """--send-test-email succeeds against the console backend (it just prints)."""
116
+ cfg_path, _ = doctor_env
117
+
118
+ # Bring schema up so the schema check passes — otherwise the failed
119
+ # schema check makes doctor exit non-zero and we can't observe the
120
+ # send-test-email check independently.
121
+ async def _install() -> None:
122
+ async with open_regstack(cfg_path) as rs:
123
+ await rs.install_schema()
124
+
125
+ asyncio.run(_install())
126
+
127
+ runner = CliRunner()
128
+ result = runner.invoke(
129
+ cli,
130
+ [
131
+ "doctor",
132
+ "--config",
133
+ str(cfg_path),
134
+ "--send-test-email",
135
+ "probe@example.com",
136
+ ],
137
+ )
138
+ assert result.exit_code == 0, result.output
139
+ assert "email send" in result.output
140
+ assert "probe@example.com" in result.output
141
+
142
+
143
+ def test_doctor_check_dns_runs_lookups(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
144
+ """--check-dns runs SPF/DKIM/MX lookups for the sender domain.
145
+
146
+ We use ``example.com`` as the sender — it's RFC 2606 reserved so it
147
+ accepts no traffic, which is fine here: we only care that doctor
148
+ actually invokes the dig probes (the labels appear in output) and
149
+ handles whatever response comes back without crashing.
150
+ """
151
+ for var in list(os.environ):
152
+ if var.startswith("REGSTACK_"):
153
+ monkeypatch.delenv(var, raising=False)
154
+ cfg_path, sqlite_path = _write_sqlite_config(tmp_path, from_address="probe@example.com")
155
+ monkeypatch.setenv("REGSTACK_JWT_SECRET", secrets.token_urlsafe(64))
156
+ monkeypatch.setenv("REGSTACK_DATABASE_URL", f"sqlite+aiosqlite:///{sqlite_path}")
157
+
158
+ runner = CliRunner()
159
+ result = runner.invoke(cli, ["doctor", "--config", str(cfg_path), "--check-dns"])
160
+ # The schema check fails (we didn't install it), so exit code ≥ 1 —
161
+ # we just care that the DNS check labels are present in output.
162
+ assert "dns mx" in result.output
163
+ assert "dns spf" in result.output
164
+ assert "dns dmarc" in result.output