regstack 0.2.4__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 (176) hide show
  1. {regstack-0.2.4 → regstack-0.2.5}/CHANGELOG.md +17 -0
  2. {regstack-0.2.4 → regstack-0.2.5}/PKG-INFO +1 -1
  3. {regstack-0.2.4 → regstack-0.2.5}/docs/changelog.md +31 -0
  4. {regstack-0.2.4 → regstack-0.2.5}/pyproject.toml +23 -1
  5. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/migrations/__init__.py +18 -12
  6. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/doctor.py +2 -2
  7. regstack-0.2.5/src/regstack/version.py +1 -0
  8. {regstack-0.2.4 → regstack-0.2.5}/tasks.py +42 -0
  9. regstack-0.2.5/tests/unit/test_cli_doctor.py +164 -0
  10. regstack-0.2.5/tests/unit/test_cli_init.py +150 -0
  11. {regstack-0.2.4 → regstack-0.2.5}/uv.lock +1 -1
  12. regstack-0.2.4/src/regstack/version.py +0 -1
  13. {regstack-0.2.4 → regstack-0.2.5}/.github/workflows/publish.yml +0 -0
  14. {regstack-0.2.4 → regstack-0.2.5}/.github/workflows/test.yml +0 -0
  15. {regstack-0.2.4 → regstack-0.2.5}/.gitignore +0 -0
  16. {regstack-0.2.4 → regstack-0.2.5}/.python-version +0 -0
  17. {regstack-0.2.4 → regstack-0.2.5}/.readthedocs.yaml +0 -0
  18. {regstack-0.2.4 → regstack-0.2.5}/CLAUDE.md +0 -0
  19. {regstack-0.2.4 → regstack-0.2.5}/LICENSE +0 -0
  20. {regstack-0.2.4 → regstack-0.2.5}/NOTICE +0 -0
  21. {regstack-0.2.4 → regstack-0.2.5}/README.md +0 -0
  22. {regstack-0.2.4 → regstack-0.2.5}/SECURITY.md +0 -0
  23. {regstack-0.2.4 → regstack-0.2.5}/docs/_static/.gitkeep +0 -0
  24. {regstack-0.2.4 → regstack-0.2.5}/docs/_templates/.gitkeep +0 -0
  25. {regstack-0.2.4 → regstack-0.2.5}/docs/api.md +0 -0
  26. {regstack-0.2.4 → regstack-0.2.5}/docs/architecture.md +0 -0
  27. {regstack-0.2.4 → regstack-0.2.5}/docs/cli.md +0 -0
  28. {regstack-0.2.4 → regstack-0.2.5}/docs/conf.py +0 -0
  29. {regstack-0.2.4 → regstack-0.2.5}/docs/configuration.md +0 -0
  30. {regstack-0.2.4 → regstack-0.2.5}/docs/embedding.md +0 -0
  31. {regstack-0.2.4 → regstack-0.2.5}/docs/index.md +0 -0
  32. {regstack-0.2.4 → regstack-0.2.5}/docs/quickstart.md +0 -0
  33. {regstack-0.2.4 → regstack-0.2.5}/docs/security.md +0 -0
  34. {regstack-0.2.4 → regstack-0.2.5}/docs/theming.md +0 -0
  35. {regstack-0.2.4 → regstack-0.2.5}/examples/_common/__init__.py +0 -0
  36. {regstack-0.2.4 → regstack-0.2.5}/examples/_common/app.py +0 -0
  37. {regstack-0.2.4 → regstack-0.2.5}/examples/mongo/README.md +0 -0
  38. {regstack-0.2.4 → regstack-0.2.5}/examples/mongo/branding/theme.css +0 -0
  39. {regstack-0.2.4 → regstack-0.2.5}/examples/mongo/main.py +0 -0
  40. {regstack-0.2.4 → regstack-0.2.5}/examples/mongo/regstack.toml +0 -0
  41. {regstack-0.2.4 → regstack-0.2.5}/examples/postgres/README.md +0 -0
  42. {regstack-0.2.4 → regstack-0.2.5}/examples/postgres/main.py +0 -0
  43. {regstack-0.2.4 → regstack-0.2.5}/examples/postgres/regstack.toml +0 -0
  44. {regstack-0.2.4 → regstack-0.2.5}/examples/sqlite/README.md +0 -0
  45. {regstack-0.2.4 → regstack-0.2.5}/examples/sqlite/main.py +0 -0
  46. {regstack-0.2.4 → regstack-0.2.5}/examples/sqlite/regstack.toml +0 -0
  47. {regstack-0.2.4 → regstack-0.2.5}/regstack.toml.example +0 -0
  48. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/__init__.py +0 -0
  49. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/app.py +0 -0
  50. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/__init__.py +0 -0
  51. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/clock.py +0 -0
  52. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/dependencies.py +0 -0
  53. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/jwt.py +0 -0
  54. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/lockout.py +0 -0
  55. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/mfa.py +0 -0
  56. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/password.py +0 -0
  57. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/auth/tokens.py +0 -0
  58. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/__init__.py +0 -0
  59. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/base.py +0 -0
  60. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/factory.py +0 -0
  61. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/__init__.py +0 -0
  62. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/backend.py +0 -0
  63. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/client.py +0 -0
  64. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/indexes.py +0 -0
  65. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  66. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  67. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  68. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  69. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
  70. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  71. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/protocols.py +0 -0
  72. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/__init__.py +0 -0
  73. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/backend.py +0 -0
  74. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/migrations/env.py +0 -0
  75. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  76. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  77. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  78. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  79. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  80. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  81. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  82. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  83. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/schema.py +0 -0
  84. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/backends/sql/types.py +0 -0
  85. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/__init__.py +0 -0
  86. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/__main__.py +0 -0
  87. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/_runtime.py +0 -0
  88. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/admin.py +0 -0
  89. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/init.py +0 -0
  90. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/cli/migrate.py +0 -0
  91. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/config/__init__.py +0 -0
  92. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/config/loader.py +0 -0
  93. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/config/schema.py +0 -0
  94. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/config/secrets.py +0 -0
  95. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/__init__.py +0 -0
  96. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/base.py +0 -0
  97. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/composer.py +0 -0
  98. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/console.py +0 -0
  99. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/factory.py +0 -0
  100. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/ses.py +0 -0
  101. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/smtp.py +0 -0
  102. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/email_change.html +0 -0
  103. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/email_change.subject.txt +0 -0
  104. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/email_change.txt +0 -0
  105. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/password_reset.html +0 -0
  106. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  107. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/password_reset.txt +0 -0
  108. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  109. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  110. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/verification.html +0 -0
  111. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/verification.subject.txt +0 -0
  112. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/email/templates/verification.txt +0 -0
  113. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/hooks/__init__.py +0 -0
  114. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/hooks/events.py +0 -0
  115. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/__init__.py +0 -0
  116. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/_objectid.py +0 -0
  117. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/login_attempt.py +0 -0
  118. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/mfa_code.py +0 -0
  119. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/pending_registration.py +0 -0
  120. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/models/user.py +0 -0
  121. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/__init__.py +0 -0
  122. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/_schemas.py +0 -0
  123. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/account.py +0 -0
  124. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/admin.py +0 -0
  125. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/login.py +0 -0
  126. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/logout.py +0 -0
  127. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/password.py +0 -0
  128. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/phone.py +0 -0
  129. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/register.py +0 -0
  130. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/routers/verify.py +0 -0
  131. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/__init__.py +0 -0
  132. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/base.py +0 -0
  133. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/factory.py +0 -0
  134. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/null.py +0 -0
  135. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/sns.py +0 -0
  136. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/sms/twilio.py +0 -0
  137. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/__init__.py +0 -0
  138. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/pages.py +0 -0
  139. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/static/css/core.css +0 -0
  140. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/static/css/theme.css +0 -0
  141. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/static/js/regstack.js +0 -0
  142. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  143. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/forgot.html +0 -0
  144. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/login.html +0 -0
  145. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/me.html +0 -0
  146. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  147. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/register.html +0 -0
  148. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/reset.html +0 -0
  149. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/auth/verify.html +0 -0
  150. {regstack-0.2.4 → regstack-0.2.5}/src/regstack/ui/templates/base.html +0 -0
  151. {regstack-0.2.4 → regstack-0.2.5}/tests/__init__.py +0 -0
  152. {regstack-0.2.4 → regstack-0.2.5}/tests/conftest.py +0 -0
  153. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/__init__.py +0 -0
  154. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_account_management.py +0 -0
  155. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_admin_router.py +0 -0
  156. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_happy_path.py +0 -0
  157. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_indexes.py +0 -0
  158. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_login_lockout.py +0 -0
  159. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_mfa.py +0 -0
  160. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_password_reset.py +0 -0
  161. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_sql_migrations.py +0 -0
  162. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_ui_router.py +0 -0
  163. {regstack-0.2.4 → regstack-0.2.5}/tests/integration/test_verification.py +0 -0
  164. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/__init__.py +0 -0
  165. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_base_install_imports.py +0 -0
  166. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_cli.py +0 -0
  167. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_config_loader.py +0 -0
  168. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_jwt.py +0 -0
  169. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_lockout.py +0 -0
  170. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_mail_composer.py +0 -0
  171. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_mfa_code_repo.py +0 -0
  172. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_password.py +0 -0
  173. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_ses_backend.py +0 -0
  174. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_sms.py +0 -0
  175. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_smtp_backend.py +0 -0
  176. {regstack-0.2.4 → regstack-0.2.5}/tests/unit/test_ui_env.py +0 -0
@@ -5,6 +5,23 @@ 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
+
8
25
  ## 0.2.4 — 2026-04-28
9
26
 
10
27
  **Breaking** — back-compat shims removed:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.2.4
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
@@ -3,6 +3,37 @@
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
+
6
37
  ## 0.2.4 — 2026-04-28
7
38
 
8
39
  **Breaking** — every back-compat shim left over from the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.2.4"
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"
@@ -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(
@@ -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
@@ -0,0 +1,150 @@
1
+ """Tests for the ``regstack init`` wizard.
2
+
3
+ The wizard is interactive (Click prompts) so we drive it via
4
+ ``CliRunner(input=...)`` and assert on the files it writes. SQLite is
5
+ used throughout so these tests need no external services.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import stat
11
+ from pathlib import Path
12
+
13
+ from click.testing import CliRunner
14
+
15
+ from regstack.cli.__main__ import cli
16
+
17
+
18
+ def _accept_all(num_prompts: int) -> str:
19
+ """Hit Enter ``num_prompts`` times — every prompt has a default."""
20
+ return "\n" * num_prompts
21
+
22
+
23
+ # Number of prompts on each happy path. If the wizard grows another
24
+ # question these counts need updating; the test failure points to it.
25
+ _SQLITE_HAPPY_PATH_PROMPTS = 17
26
+ _SMTP_PATH_PROMPTS = 17 + 4 # smtp host/port/starttls/user/pass replace 0
27
+ _SES_PATH_PROMPTS = 17 + 1
28
+ _MONGO_PATH_PROMPTS = 17 + 1
29
+ _POSTGRES_PATH_PROMPTS = 17
30
+
31
+
32
+ def test_init_writes_sqlite_config_with_defaults(tmp_path: Path) -> None:
33
+ runner = CliRunner()
34
+ result = runner.invoke(
35
+ cli,
36
+ ["init", "--target", str(tmp_path)],
37
+ input=_accept_all(_SQLITE_HAPPY_PATH_PROMPTS),
38
+ )
39
+ assert result.exit_code == 0, result.output
40
+
41
+ cfg = tmp_path / "regstack.toml"
42
+ secrets = tmp_path / "regstack.secrets.env"
43
+ assert cfg.exists()
44
+ assert secrets.exists()
45
+
46
+ cfg_text = cfg.read_text()
47
+ assert 'app_name = "MyApp"' in cfg_text
48
+ assert 'base_url = "http://localhost:8000"' in cfg_text
49
+ assert "[email]" in cfg_text
50
+ assert 'backend = "console"' in cfg_text
51
+ # SQLite default writes no mongodb_database (it's omitted on non-mongo paths)
52
+ assert "mongodb_database" not in cfg_text
53
+
54
+ secrets_text = secrets.read_text()
55
+ assert "REGSTACK_JWT_SECRET=" in secrets_text
56
+ assert "REGSTACK_DATABASE_URL=sqlite+aiosqlite:///./myapp.sqlite" in secrets_text
57
+
58
+ # Mode 0600 — secrets file should not be world-readable.
59
+ mode = stat.S_IMODE(secrets.stat().st_mode)
60
+ assert mode == 0o600, oct(mode)
61
+
62
+ assert "Wrote " in result.output
63
+
64
+
65
+ def test_init_postgres_backend(tmp_path: Path) -> None:
66
+ runner = CliRunner()
67
+ # 17 prompts, but the database-backend prompt picks postgres → triggers
68
+ # the Postgres URL prompt (which adds 1) but skips the SQLite-path
69
+ # prompt the SQLite branch added. Net = 17.
70
+ # Prompt sequence to reach postgres branch: defaults until the
71
+ # backend prompt (5 defaults), then "postgres", then defaults.
72
+ # We feed: 4x default, "postgres", 12x default.
73
+ inputs = "\n\n\n\n" + "postgres\n" + "\n" * 12
74
+ result = runner.invoke(cli, ["init", "--target", str(tmp_path)], input=inputs)
75
+ assert result.exit_code == 0, result.output
76
+
77
+ secrets_text = (tmp_path / "regstack.secrets.env").read_text()
78
+ assert "REGSTACK_DATABASE_URL=postgresql+asyncpg://postgres@localhost/myapp" in secrets_text
79
+
80
+
81
+ def test_init_mongo_backend(tmp_path: Path) -> None:
82
+ runner = CliRunner()
83
+ # 4x default, "mongo", then default host, default db, then 11x default.
84
+ inputs = "\n\n\n\n" + "mongo\n" + "\n\n" + "\n" * 11
85
+ result = runner.invoke(cli, ["init", "--target", str(tmp_path)], input=inputs)
86
+ assert result.exit_code == 0, result.output
87
+
88
+ cfg_text = (tmp_path / "regstack.toml").read_text()
89
+ assert 'mongodb_database = "myapp"' in cfg_text
90
+
91
+ secrets_text = (tmp_path / "regstack.secrets.env").read_text()
92
+ assert "REGSTACK_DATABASE_URL=mongodb://localhost:27017/myapp" in secrets_text
93
+
94
+
95
+ def test_init_refuses_to_overwrite_without_force(tmp_path: Path) -> None:
96
+ (tmp_path / "regstack.toml").write_text("# existing\n")
97
+ runner = CliRunner()
98
+ # The overwrite confirm prompt defaults to "no" (abort=True).
99
+ # Sending "n\n" rejects the prompt -> Click aborts with exit 1.
100
+ result = runner.invoke(cli, ["init", "--target", str(tmp_path)], input="n\n")
101
+ assert result.exit_code == 1
102
+ assert "Overwrite?" in result.output
103
+
104
+
105
+ def test_init_force_overwrites(tmp_path: Path) -> None:
106
+ (tmp_path / "regstack.toml").write_text("# existing\n")
107
+ runner = CliRunner()
108
+ result = runner.invoke(
109
+ cli,
110
+ ["init", "--target", str(tmp_path), "--force"],
111
+ input=_accept_all(_SQLITE_HAPPY_PATH_PROMPTS),
112
+ )
113
+ assert result.exit_code == 0, result.output
114
+ cfg_text = (tmp_path / "regstack.toml").read_text()
115
+ assert "# existing" not in cfg_text
116
+ assert 'app_name = "MyApp"' in cfg_text
117
+
118
+
119
+ def test_init_smtp_backend_records_smtp_settings(tmp_path: Path) -> None:
120
+ """Pick SMTP at the email prompt → wizard asks for host/port/starttls/user/pass."""
121
+ # Sequence: app/base/cookie/proxy (4 defaults), backend default (sqlite),
122
+ # sqlite path default, jwt/jwt-ttl/transport (3 defaults),
123
+ # email backend = "smtp", from-address default, from-name default,
124
+ # smtp host = "mail.example.com", smtp port default (587),
125
+ # smtp starttls default (yes), smtp user = "u", smtp pass = "p",
126
+ # then SMS no, admin no, ui no, register yes, verify no.
127
+ inputs = (
128
+ "\n" * 4 # app, base, cookie, proxy
129
+ + "\n" # backend = sqlite
130
+ + "\n" # sqlite path
131
+ + "\n\n\n" # jwt secret auto, jwt ttl, transport
132
+ + "smtp\n" # email backend
133
+ + "\n\n" # from-address, from-name
134
+ + "mail.example.com\n" # smtp host
135
+ + "\n\n" # smtp port, starttls
136
+ + "u\np\n" # smtp user, pass
137
+ + "\n\n\n\n\n" # sms, admin, ui, register, verify
138
+ )
139
+ runner = CliRunner()
140
+ result = runner.invoke(cli, ["init", "--target", str(tmp_path)], input=inputs)
141
+ assert result.exit_code == 0, result.output
142
+
143
+ cfg_text = (tmp_path / "regstack.toml").read_text()
144
+ assert 'smtp_host = "mail.example.com"' in cfg_text
145
+ assert "smtp_port = 587" in cfg_text
146
+ assert "smtp_starttls = true" in cfg_text
147
+ assert 'smtp_username = "u"' in cfg_text
148
+
149
+ secrets_text = (tmp_path / "regstack.secrets.env").read_text()
150
+ assert "REGSTACK_EMAIL__SMTP_PASSWORD=p" in secrets_text
@@ -1978,7 +1978,7 @@ wheels = [
1978
1978
 
1979
1979
  [[package]]
1980
1980
  name = "regstack"
1981
- version = "0.2.4"
1981
+ version = "0.2.5"
1982
1982
  source = { editable = "." }
1983
1983
  dependencies = [
1984
1984
  { name = "aiosmtplib" },
@@ -1 +0,0 @@
1
- __version__ = "0.2.4"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes