pgdevkit 0.6.2__tar.gz → 0.7.1__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 (131) hide show
  1. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/PKG-INFO +31 -3
  2. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/README.md +30 -2
  3. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/cli.py +23 -2
  4. pgdevkit-0.7.1/pgdevkit/testdb/__init__.py +23 -0
  5. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/api.py +64 -15
  6. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/config.py +13 -0
  7. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/api.py +37 -17
  8. pgdevkit-0.7.1/pgdevkit/testdb/naming.py +112 -0
  9. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pyproject.toml +1 -1
  10. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/conftest.py +20 -0
  11. pgdevkit-0.7.1/tests/testdb/test_api.py +244 -0
  12. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_cli.py +24 -0
  13. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_config.py +23 -0
  14. pgdevkit-0.7.1/tests/testdb/test_naming.py +121 -0
  15. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/uv.lock +1 -1
  16. pgdevkit-0.6.2/pgdevkit/testdb/__init__.py +0 -3
  17. pgdevkit-0.6.2/pgdevkit/testdb/naming.py +0 -41
  18. pgdevkit-0.6.2/tests/testdb/test_api.py +0 -114
  19. pgdevkit-0.6.2/tests/testdb/test_naming.py +0 -51
  20. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/auto-release.yml +0 -0
  21. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/python-publish.yml +0 -0
  22. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.github/workflows/python-test.yml +0 -0
  23. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.gitignore +0 -0
  24. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/.python-version +0 -0
  25. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/docs/database-layout.md +0 -0
  26. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/__init__.py +0 -0
  27. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/areas.py +0 -0
  28. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/__init__.py +0 -0
  29. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/base.py +0 -0
  30. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/mssql.py +0 -0
  31. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/backends/postgres.py +0 -0
  32. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/connection.py +0 -0
  33. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/__init__.py +0 -0
  34. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/complex_types.py +0 -0
  35. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/connection.py +0 -0
  36. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/crud.py +0 -0
  37. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/loader.py +0 -0
  38. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/model.py +0 -0
  39. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/mssql_crud.py +0 -0
  40. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/db/mssql_sql.py +0 -0
  41. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/dialect.py +0 -0
  42. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/diff.py +0 -0
  43. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/envtag.py +0 -0
  44. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/fetch_missing.py +0 -0
  45. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/introspect.py +0 -0
  46. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/lakebase.py +0 -0
  47. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/migrate.py +0 -0
  48. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/models.py +0 -0
  49. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/mssql_introspect.py +0 -0
  50. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/parser.py +0 -0
  51. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/schemas.py +0 -0
  52. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/sql_text.py +0 -0
  53. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/_docker.py +0 -0
  54. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/constants.py +0 -0
  55. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/container.py +0 -0
  56. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  57. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  58. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/mssql/container.py +0 -0
  59. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/query.py +0 -0
  60. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/pgdevkit/testdb/schema.py +0 -0
  61. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/SKILL.md +0 -0
  62. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  63. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  64. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/__init__.py +0 -0
  65. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/conftest.py +0 -0
  66. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/__init__.py +0 -0
  67. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_complex_types.py +0 -0
  68. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_connection.py +0 -0
  69. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_crud.py +0 -0
  70. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_loader.py +0 -0
  71. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_mssql_crud_live.py +0 -0
  72. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/db/test_mssql_crud_sql.py +0 -0
  73. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/01_schema.sql +0 -0
  74. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/02_types.sql +0 -0
  75. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/03_tables.sql +0 -0
  76. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/04_views.sql +0 -0
  77. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/05_functions.sql +0 -0
  78. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/fixtures/06_indexes.sql +0 -0
  79. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_areas.py +0 -0
  80. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_cli_compare.py +0 -0
  81. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_compare.py +0 -0
  82. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_compare_mssql_live.py +0 -0
  83. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_connection.py +0 -0
  84. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_dialect.py +0 -0
  85. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_diff_mssql.py +0 -0
  86. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_envtag.py +0 -0
  87. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_fetch_missing.py +0 -0
  88. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_fetch_missing_cli.py +0 -0
  89. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_lakebase.py +0 -0
  90. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate.py +0 -0
  91. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_areas.py +0 -0
  92. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_env.py +0 -0
  93. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_migrate_schemas.py +0 -0
  94. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_mssql_introspect.py +0 -0
  95. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_areas.py +0 -0
  96. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_mssql.py +0 -0
  97. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_parser_schemas.py +0 -0
  98. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/test_schemas.py +0 -0
  99. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/__init__.py +0 -0
  100. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  101. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  102. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  103. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  104. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  105. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  106. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  107. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  108. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  109. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  110. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  111. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  112. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  113. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  114. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  115. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  116. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  117. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  118. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  119. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  120. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  121. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  122. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_api_mssql.py +0 -0
  123. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_api_mssql_live.py +0 -0
  124. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_config_mssql.py +0 -0
  125. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_constants.py +0 -0
  126. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_container.py +0 -0
  127. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_mssql_constants.py +0 -0
  128. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_query.py +0 -0
  129. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_schema.py +0 -0
  130. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_schema_filtering.py +0 -0
  131. {pgdevkit-0.6.2 → pgdevkit-0.7.1}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.6.2
3
+ Version: 0.7.1
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -221,8 +221,8 @@ def ensure_test_postgres():
221
221
  os.environ[k] = v
222
222
  ```
223
223
 
224
- CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
225
- `--env` (default `local_test`) — see "Environment-tagged files" above.
224
+ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
225
+ accept `--env` (default `local_test`) — see "Environment-tagged files" above.
226
226
 
227
227
  `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
228
228
  (see "Area and schema filtering" above) to scope which `database/` files get
@@ -231,6 +231,34 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
231
231
  project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
232
232
  keyword arguments when called from Python (e.g. from a pytest fixture).
233
233
 
234
+ Every git worktree/branch of a project gets its own database, named after
235
+ `project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
236
+ Removing a worktree (or deleting its directory without `git worktree
237
+ remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
238
+ this project's databases whose worktree no longer exists, and
239
+ `pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
240
+ drops every database of this project regardless of whether its worktree is
241
+ still live). The same is available from Python as
242
+ `pgdevkit.testdb.find_orphaned_dbs()` and
243
+ `pgdevkit.testdb.clean_testdb(orphaned=True)`.
244
+
245
+ If your project's own test setup also creates a sibling database per
246
+ worktree (e.g. `<main_db>_myservice` for a mock service used only by that
247
+ project's tests), add its literal suffix so orphan detection knows it
248
+ belongs to a live worktree too:
249
+
250
+ ```toml
251
+ [tool.pgdevkit]
252
+ extra_db_suffixes = ["_myservice"]
253
+ ```
254
+
255
+ `pgdevkit.testdb.workspace_db_names(project_root=None)` is the
256
+ single-workspace analog of `find_orphaned_dbs()`: it returns the exact set
257
+ of DB names (main + any `extra_db_suffixes`) owned by the branch currently
258
+ checked out at `project_root`, without touching Postgres or diffing against
259
+ other worktrees. Useful for a caller that's about to remove one specific
260
+ worktree and wants to know exactly which DB(s) go with it.
261
+
234
262
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
235
263
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
236
264
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -200,8 +200,8 @@ def ensure_test_postgres():
200
200
  os.environ[k] = v
201
201
  ```
202
202
 
203
- CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
204
- `--env` (default `local_test`) — see "Environment-tagged files" above.
203
+ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
204
+ accept `--env` (default `local_test`) — see "Environment-tagged files" above.
205
205
 
206
206
  `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
207
207
  (see "Area and schema filtering" above) to scope which `database/` files get
@@ -210,6 +210,34 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
210
210
  project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
211
211
  keyword arguments when called from Python (e.g. from a pytest fixture).
212
212
 
213
+ Every git worktree/branch of a project gets its own database, named after
214
+ `project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
215
+ Removing a worktree (or deleting its directory without `git worktree
216
+ remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
217
+ this project's databases whose worktree no longer exists, and
218
+ `pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
219
+ drops every database of this project regardless of whether its worktree is
220
+ still live). The same is available from Python as
221
+ `pgdevkit.testdb.find_orphaned_dbs()` and
222
+ `pgdevkit.testdb.clean_testdb(orphaned=True)`.
223
+
224
+ If your project's own test setup also creates a sibling database per
225
+ worktree (e.g. `<main_db>_myservice` for a mock service used only by that
226
+ project's tests), add its literal suffix so orphan detection knows it
227
+ belongs to a live worktree too:
228
+
229
+ ```toml
230
+ [tool.pgdevkit]
231
+ extra_db_suffixes = ["_myservice"]
232
+ ```
233
+
234
+ `pgdevkit.testdb.workspace_db_names(project_root=None)` is the
235
+ single-workspace analog of `find_orphaned_dbs()`: it returns the exact set
236
+ of DB names (main + any `extra_db_suffixes`) owned by the branch currently
237
+ checked out at `project_root`, without touching Postgres or diffing against
238
+ other worktrees. Useful for a caller that's about to remove one specific
239
+ worktree and wants to know exactly which DB(s) go with it.
240
+
213
241
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
214
242
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
215
243
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -302,12 +302,33 @@ def testdb_shell() -> None:
302
302
  @testdb_app.command("clean")
303
303
  def testdb_clean(
304
304
  all: bool = typer.Option(False, "--all", help="Drop every database belonging to this project"),
305
+ orphaned: bool = typer.Option(
306
+ False,
307
+ "--orphaned",
308
+ help="Drop only databases whose git worktree no longer exists (see `testdb list-orphaned`)",
309
+ ),
305
310
  ) -> None:
306
- """Drop this workspace's database (or every database of this project with --all)."""
307
- testdb.clean_testdb(all=all)
311
+ """Drop this workspace's database (or every database of this project with --all,
312
+ or only its orphaned ones with --orphaned)."""
313
+ if all and orphaned:
314
+ err_console.print("[red]Error:[/red] pass at most one of --all, --orphaned")
315
+ raise typer.Exit(2)
316
+ testdb.clean_testdb(all=all, orphaned=orphaned)
308
317
  console.print("[green]Cleaned.[/green]")
309
318
 
310
319
 
320
+ @testdb_app.command("list-orphaned")
321
+ def testdb_list_orphaned() -> None:
322
+ """List this project's databases whose git worktree no longer exists,
323
+ without dropping them."""
324
+ names = testdb.find_orphaned_dbs()
325
+ if not names:
326
+ console.print("No orphaned databases.")
327
+ return
328
+ for name in names:
329
+ console.print(name)
330
+
331
+
311
332
  @migrate_app.command("check")
312
333
  def migrate_check(
313
334
  migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
@@ -0,0 +1,23 @@
1
+ from .api import (
2
+ clean_testdb,
3
+ dsn_for,
4
+ ensure_testdb,
5
+ find_orphaned_dbs,
6
+ reset_testdb,
7
+ run_sql,
8
+ shell_argv,
9
+ status,
10
+ workspace_db_names,
11
+ )
12
+
13
+ __all__ = [
14
+ "clean_testdb",
15
+ "dsn_for",
16
+ "ensure_testdb",
17
+ "find_orphaned_dbs",
18
+ "reset_testdb",
19
+ "run_sql",
20
+ "shell_argv",
21
+ "status",
22
+ "workspace_db_names",
23
+ ]
@@ -9,7 +9,14 @@ from psycopg.sql import SQL, Identifier
9
9
  from . import constants, query
10
10
  from .config import ProjectConfig, load_config
11
11
  from .container import ensure_container
12
- from .naming import current_branch, slugify, workspace_db_name
12
+ from .naming import (
13
+ current_branch,
14
+ escape_like_prefix,
15
+ expected_db_names,
16
+ live_worktree_branches,
17
+ slugify,
18
+ workspace_db_name,
19
+ )
13
20
  from .schema import apply_schema
14
21
 
15
22
 
@@ -68,6 +75,16 @@ async def _drop_database(db_name: str) -> None:
68
75
  await con.execute(SQL("DROP DATABASE IF EXISTS {}").format(Identifier(db_name)))
69
76
 
70
77
 
78
+ async def _dbs_with_prefix(prefix: str) -> list[str]:
79
+ escaped_prefix = escape_like_prefix(prefix)
80
+ async with await psycopg.AsyncConnection.connect(_admin_dsn(), autocommit=True) as con:
81
+ result = await con.execute(
82
+ "SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
83
+ {"pattern": f"{escaped_prefix}%"},
84
+ )
85
+ return [row[0] for row in await result.fetchall()]
86
+
87
+
71
88
  async def _apply(
72
89
  config: ProjectConfig,
73
90
  db_name: str,
@@ -154,27 +171,59 @@ def reset_testdb(
154
171
  )
155
172
 
156
173
 
157
- def clean_testdb(project_root: Path | None = None, all: bool = False) -> None:
174
+ async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
175
+ prefix = f"{slugify(config.name)}_"
176
+ actual = await _dbs_with_prefix(prefix)
177
+ expected = expected_db_names(config, live_worktree_branches(config.root))
178
+ return sorted(set(actual) - expected)
179
+
180
+
181
+ def find_orphaned_dbs(project_root: Path | None = None) -> list[str]:
182
+ """Databases belonging to this project (matched by its name-slug prefix)
183
+ that don't belong to any currently live git worktree of this repo --
184
+ i.e. their branch's worktree was removed (or never existed) without
185
+ also dropping its database."""
186
+ config, _ = _resolve(project_root)
187
+ if config.engine == "mssql":
188
+ return _mssql_api().find_orphaned_dbs(config)
189
+ return asyncio.run(_find_orphaned_dbs(config))
190
+
191
+
192
+ def workspace_db_names(project_root: Path | None = None) -> frozenset[str]:
193
+ """Every DB name this exact workspace (the branch currently checked out
194
+ at `project_root`) owns: its main workspace DB plus one
195
+ `<main>{suffix}` sibling per configured `extra_db_suffixes` entry. The
196
+ single-workspace analog of what `find_orphaned_dbs` computes across
197
+ every *live* worktree -- for a caller that wants "which DBs belong to
198
+ this one worktree right now" (e.g. to drop them before removing the
199
+ worktree itself), as opposed to a whole-project orphan sweep. Engine
200
+ (postgres/mssql) doesn't affect naming, so this doesn't dispatch on it."""
201
+ config = load_config(project_root)
202
+ branch = current_branch(config.root)
203
+ return frozenset(expected_db_names(config, [branch]))
204
+
205
+
206
+ def clean_testdb(project_root: Path | None = None, all: bool = False, orphaned: bool = False) -> None:
158
207
  """Drop this workspace's database. With all=True, drop every database
159
208
  belonging to this project (matched by its name-slug prefix), across
160
- every worktree/branch."""
209
+ every worktree/branch. With orphaned=True, drop only those without a
210
+ currently live git worktree (see `find_orphaned_dbs`). At most one of
211
+ all/orphaned may be set."""
212
+ if all and orphaned:
213
+ raise ValueError("clean_testdb: pass at most one of all=True, orphaned=True")
214
+
161
215
  config, db_name = _resolve(project_root)
162
216
  if config.engine == "mssql":
163
- _mssql_api().clean_testdb(config, db_name, all)
217
+ _mssql_api().clean_testdb(config, db_name, all, orphaned)
164
218
  return
165
219
 
166
220
  async def _run() -> None:
167
- if not all:
168
- await _drop_database(db_name)
169
- return
170
- prefix = f"{slugify(config.name)}_"
171
- escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
172
- async with await psycopg.AsyncConnection.connect(_admin_dsn(), autocommit=True) as con:
173
- result = await con.execute(
174
- "SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
175
- {"pattern": f"{escaped_prefix}%"},
176
- )
177
- names = [row[0] for row in await result.fetchall()]
221
+ if orphaned:
222
+ names = await _find_orphaned_dbs(config)
223
+ elif all:
224
+ names = await _dbs_with_prefix(f"{slugify(config.name)}_")
225
+ else:
226
+ names = [db_name]
178
227
  for name in names:
179
228
  await _drop_database(name)
180
229
 
@@ -14,6 +14,7 @@ class ProjectConfig:
14
14
  database_dir: str = "database"
15
15
  env_prefix: str = ""
16
16
  extensions: tuple[str, ...] = ()
17
+ extra_db_suffixes: tuple[str, ...] = ()
17
18
  engine: str = "postgres"
18
19
  root: Path = field(default_factory=Path)
19
20
 
@@ -48,6 +49,17 @@ def load_config(start: Path | None = None) -> ProjectConfig:
48
49
  f"[tool.pgdevkit].extensions in {pyproject} must be a list, got {type(extensions).__name__}"
49
50
  )
50
51
 
52
+ # Lets a repo that layers an extra, literally-suffixed sibling database
53
+ # on top of its main workspace DB (e.g. a mock-service DB used only by
54
+ # that repo's own test setup) teach `find_orphaned_dbs`/`clean_testdb`
55
+ # about it, without pgdevkit needing to know why that suffix exists.
56
+ extra_db_suffixes = section.get("extra_db_suffixes", [])
57
+ if not isinstance(extra_db_suffixes, list):
58
+ raise TypeError(
59
+ f"[tool.pgdevkit].extra_db_suffixes in {pyproject} must be a list, "
60
+ f"got {type(extra_db_suffixes).__name__}"
61
+ )
62
+
51
63
  # PGDEVKIT_TESTDB_ENGINE lets CI/ad-hoc runs flip engines without
52
64
  # editing pyproject.toml; the toml value is the durable, per-project
53
65
  # default (a project's database/ tree is written in one dialect, so
@@ -59,6 +71,7 @@ def load_config(start: Path | None = None) -> ProjectConfig:
59
71
  database_dir=section.get("database_dir", "database"),
60
72
  env_prefix=section.get("env_prefix", ""),
61
73
  extensions=tuple(extensions),
74
+ extra_db_suffixes=tuple(extra_db_suffixes),
62
75
  engine=engine,
63
76
  root=root,
64
77
  )
@@ -168,26 +168,46 @@ def ensure_testdb(
168
168
  return _env_for(config, db_name)
169
169
 
170
170
 
171
- def clean_testdb(config: ProjectConfig, db_name: str, all: bool) -> None:
172
- from ..naming import slugify
171
+ async def _dbs_with_prefix(prefix: str) -> list[str]:
172
+ from ..naming import escape_like_prefix
173
173
 
174
- async def _run() -> None:
175
- if not all:
176
- await _drop_database(db_name)
177
- return
178
- prefix = f"{slugify(config.name)}_"
179
- escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
174
+ escaped_prefix = escape_like_prefix(prefix)
180
175
 
181
- def _list_names() -> list[str]:
182
- conn = mssql_python.connect(_admin_dsn(), autocommit=True)
183
- try:
184
- cur = conn.cursor()
185
- cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"])
186
- return [row[0] for row in cur.fetchall()]
187
- finally:
188
- conn.close()
176
+ def _list_names() -> list[str]:
177
+ conn = mssql_python.connect(_admin_dsn(), autocommit=True)
178
+ try:
179
+ cur = conn.cursor()
180
+ cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"])
181
+ return [row[0] for row in cur.fetchall()]
182
+ finally:
183
+ conn.close()
189
184
 
190
- names = await asyncio.to_thread(_list_names)
185
+ return await asyncio.to_thread(_list_names)
186
+
187
+
188
+ async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
189
+ from ..naming import expected_db_names, live_worktree_branches, slugify
190
+
191
+ prefix = f"{slugify(config.name)}_"
192
+ actual = await _dbs_with_prefix(prefix)
193
+ expected = expected_db_names(config, live_worktree_branches(config.root))
194
+ return sorted(set(actual) - expected)
195
+
196
+
197
+ def find_orphaned_dbs(config: ProjectConfig) -> list[str]:
198
+ return asyncio.run(_find_orphaned_dbs(config))
199
+
200
+
201
+ def clean_testdb(config: ProjectConfig, db_name: str, all: bool, orphaned: bool = False) -> None:
202
+ from ..naming import slugify
203
+
204
+ async def _run() -> None:
205
+ if orphaned:
206
+ names = await _find_orphaned_dbs(config)
207
+ elif all:
208
+ names = await _dbs_with_prefix(f"{slugify(config.name)}_")
209
+ else:
210
+ names = [db_name]
191
211
  for name in names:
192
212
  await _drop_database(name)
193
213
 
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .config import ProjectConfig
11
+
12
+ _INVALID_CHARS = re.compile(r"[^a-z0-9_]+")
13
+ _MAX_SLUG_LEN = 30
14
+
15
+
16
+ def slugify(value: str) -> str:
17
+ """Lowercase, replace invalid chars with '_', truncate+hash if too long."""
18
+ slug = _INVALID_CHARS.sub("_", value.lower()).strip("_")
19
+ if not slug:
20
+ slug = "x"
21
+ if len(slug) <= _MAX_SLUG_LEN:
22
+ return slug
23
+ digest = hashlib.sha256(slug.encode()).hexdigest()[:8]
24
+ return f"{slug[:_MAX_SLUG_LEN]}_{digest}"
25
+
26
+
27
+ def current_branch(cwd: Path | None = None) -> str:
28
+ """Return the branch checked out in the git worktree rooted at cwd."""
29
+ result = subprocess.run(
30
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
31
+ cwd=cwd,
32
+ capture_output=True,
33
+ text=True,
34
+ check=True,
35
+ )
36
+ return result.stdout.strip()
37
+
38
+
39
+ def workspace_db_name(project_name: str, branch: str) -> str:
40
+ """Compute a Postgres-safe, collision-resistant database name for this
41
+ project+branch. A second slugify pass over the joined string guarantees
42
+ the result stays under Postgres's 63-byte identifier limit even when
43
+ both inputs are already at the per-component truncation limit."""
44
+ joined = f"{slugify(project_name)}_{slugify(branch)}"
45
+ return slugify(joined)
46
+
47
+
48
+ def live_worktree_branches(repo: Path) -> list[str]:
49
+ """Branches checked out in every currently-live worktree of the repo
50
+ containing `repo` -- "live" meaning its filesystem path still exists and
51
+ it isn't a bare checkout. `git worktree list` reports every worktree of
52
+ a repo regardless of which one it's run from, so this works whether
53
+ `repo` is the main checkout or a linked worktree.
54
+
55
+ A detached-HEAD worktree (no `branch ...` porcelain line) is reported as
56
+ branch "HEAD" -- matching what `current_branch()` (and so
57
+ `workspace_db_name()`) computes for that same worktree via `git
58
+ rev-parse --abbrev-ref HEAD`. Without this, a detached-HEAD worktree
59
+ (e.g. a CI checkout, which defaults to one) would never appear "live"
60
+ here, and its database would look orphaned and get dropped out from
61
+ under it."""
62
+ result = subprocess.run(
63
+ ["git", "worktree", "list", "--porcelain"],
64
+ cwd=repo,
65
+ capture_output=True,
66
+ text=True,
67
+ check=True,
68
+ )
69
+
70
+ branches: list[str] = []
71
+ path: Path | None = None
72
+ branch: str | None = None
73
+ bare = False
74
+
75
+ def _flush() -> None:
76
+ if path is not None and not bare and branch is not None and path.exists():
77
+ branches.append(branch)
78
+
79
+ for line in result.stdout.splitlines():
80
+ if not line:
81
+ _flush()
82
+ path, branch, bare = None, None, False
83
+ elif line.startswith("worktree "):
84
+ path = Path(line[len("worktree ") :])
85
+ elif line.startswith("branch "):
86
+ branch = line[len("branch ") :].removeprefix("refs/heads/")
87
+ elif line == "detached":
88
+ branch = "HEAD"
89
+ elif line == "bare":
90
+ bare = True
91
+ _flush()
92
+
93
+ return branches
94
+
95
+
96
+ def escape_like_prefix(prefix: str) -> str:
97
+ """Escape a literal string for use as a `LIKE ... ESCAPE '\\'` prefix
98
+ pattern (with a trailing `%` the caller adds), so a project name/branch
99
+ containing `%` or `_` can't widen the match to an unrelated database."""
100
+ return prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
101
+
102
+
103
+ def expected_db_names(config: "ProjectConfig", branches: list[str]) -> set[str]:
104
+ """Every DB name a currently live worktree of this project is entitled
105
+ to own: the main workspace DB per branch, plus one `<main>{suffix}`
106
+ sibling per `[tool.pgdevkit].extra_db_suffixes` entry."""
107
+ names: set[str] = set()
108
+ for branch in branches:
109
+ main_db = workspace_db_name(config.name, branch)
110
+ names.add(main_db)
111
+ names.update(f"{main_db}{suffix}" for suffix in config.extra_db_suffixes)
112
+ return names
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.6.2"
14
+ version = "0.7.1"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -74,3 +74,23 @@ def project_factory(tmp_path: Path) -> Callable[..., Path]:
74
74
  return _make_project(tmp_path, name, branch, engine)
75
75
 
76
76
  return _factory
77
+
78
+
79
+ @pytest.fixture
80
+ def worktree_project_factory(tmp_path: Path) -> Callable[..., tuple[Path, Callable[[str], Path]]]:
81
+ """Build a real project repo (with a `[tool.pgdevkit]` pyproject.toml,
82
+ like `project_factory`) plus a `add_worktree(branch)` helper that adds a
83
+ linked `git worktree` of that same repo -- for tests that need more than
84
+ one *live* worktree of one repo, e.g. orphaned-DB detection."""
85
+
86
+ def _factory(name: str, main_branch: str = "main", engine: str = "postgres") -> tuple[Path, Callable[[str], Path]]:
87
+ repo = _make_project(tmp_path, name, main_branch, engine)
88
+
89
+ def _add_worktree(branch: str) -> Path:
90
+ path = tmp_path / f"{name}-{branch}"
91
+ subprocess.run(["git", "worktree", "add", "-q", str(path), "-b", branch], cwd=repo, check=True)
92
+ return path
93
+
94
+ return repo, _add_worktree
95
+
96
+ return _factory