pgdevkit 0.3.8__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. pgdevkit-0.4.0/.github/workflows/auto-release.yml +83 -0
  2. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/PKG-INFO +83 -1
  3. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/README.md +82 -0
  4. pgdevkit-0.4.0/pgdevkit/areas.py +64 -0
  5. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/cli.py +39 -5
  6. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/fetch_missing.py +6 -1
  7. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/migrate.py +20 -4
  8. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/parser.py +28 -6
  9. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pyproject.toml +1 -1
  10. pgdevkit-0.4.0/tests/test_areas.py +106 -0
  11. pgdevkit-0.4.0/tests/test_migrate_areas.py +41 -0
  12. pgdevkit-0.4.0/tests/test_parser_areas.py +34 -0
  13. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/uv.lock +1 -1
  14. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.github/workflows/python-publish.yml +0 -0
  15. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.github/workflows/python-test.yml +0 -0
  16. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.gitignore +0 -0
  17. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/.python-version +0 -0
  18. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/docs/database-layout.md +0 -0
  19. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/__init__.py +0 -0
  20. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/__init__.py +0 -0
  21. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/base.py +0 -0
  22. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/mssql.py +0 -0
  23. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/backends/postgres.py +0 -0
  24. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/connection.py +0 -0
  25. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/__init__.py +0 -0
  26. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/complex_types.py +0 -0
  27. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/connection.py +0 -0
  28. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/crud.py +0 -0
  29. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/loader.py +0 -0
  30. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/model.py +0 -0
  31. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/mssql_crud.py +0 -0
  32. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/db/mssql_sql.py +0 -0
  33. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/dialect.py +0 -0
  34. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/diff.py +0 -0
  35. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/introspect.py +0 -0
  36. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/lakebase.py +0 -0
  37. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/models.py +0 -0
  38. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/mssql_introspect.py +0 -0
  39. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/__init__.py +0 -0
  40. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/_docker.py +0 -0
  41. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/api.py +0 -0
  42. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/config.py +0 -0
  43. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/constants.py +0 -0
  44. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/container.py +0 -0
  45. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  46. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/api.py +0 -0
  47. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  48. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/mssql/container.py +0 -0
  49. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/naming.py +0 -0
  50. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/query.py +0 -0
  51. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/pgdevkit/testdb/schema.py +0 -0
  52. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/SKILL.md +0 -0
  53. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  54. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  55. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/__init__.py +0 -0
  56. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/conftest.py +0 -0
  57. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/__init__.py +0 -0
  58. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_complex_types.py +0 -0
  59. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_connection.py +0 -0
  60. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_crud.py +0 -0
  61. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_loader.py +0 -0
  62. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_mssql_crud_live.py +0 -0
  63. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/db/test_mssql_crud_sql.py +0 -0
  64. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/01_schema.sql +0 -0
  65. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/02_types.sql +0 -0
  66. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/03_tables.sql +0 -0
  67. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/04_views.sql +0 -0
  68. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/05_functions.sql +0 -0
  69. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/fixtures/06_indexes.sql +0 -0
  70. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_cli_compare.py +0 -0
  71. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_compare.py +0 -0
  72. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_compare_mssql_live.py +0 -0
  73. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_connection.py +0 -0
  74. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_dialect.py +0 -0
  75. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_diff_mssql.py +0 -0
  76. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_fetch_missing.py +0 -0
  77. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_fetch_missing_cli.py +0 -0
  78. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_lakebase.py +0 -0
  79. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_migrate.py +0 -0
  80. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_mssql_introspect.py +0 -0
  81. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/test_parser_mssql.py +0 -0
  82. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/__init__.py +0 -0
  83. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/conftest.py +0 -0
  84. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  85. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  86. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  87. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  88. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  89. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  90. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  91. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  92. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  93. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  94. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  95. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  96. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  97. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  98. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  99. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  100. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  101. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api.py +0 -0
  102. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api_mssql.py +0 -0
  103. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_api_mssql_live.py +0 -0
  104. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_cli.py +0 -0
  105. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_config.py +0 -0
  106. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_config_mssql.py +0 -0
  107. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_constants.py +0 -0
  108. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_container.py +0 -0
  109. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_mssql_constants.py +0 -0
  110. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_naming.py +0 -0
  111. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_query.py +0 -0
  112. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_schema.py +0 -0
  113. {pgdevkit-0.3.8 → pgdevkit-0.4.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -0,0 +1,83 @@
1
+ # Auto-tags, cuts a GitHub Release, and dispatches a PyPI publish whenever
2
+ # pyproject.toml's version changes on main and the Python Test workflow has
3
+ # passed for that commit. No manual "cut a release" step needed.
4
+ #
5
+ # Two GitHub Actions quirks shaped this design -- both hit and confirmed the
6
+ # hard way, so read before "simplifying" this:
7
+ #
8
+ # 1. GitHub Actions doesn't fire other workflows' triggers (release:
9
+ # published included) for events performed with the automatic
10
+ # GITHUB_TOKEN, to prevent recursive runs. So creating the release here
11
+ # would NOT, on its own, trigger python-publish.yml's `release: published`
12
+ # listener. workflow_dispatch is the documented exception to that rule --
13
+ # an API-triggered workflow_dispatch DOES start a new run even when
14
+ # triggered by GITHUB_TOKEN -- so this dispatches python-publish.yml
15
+ # directly instead of relying on the release event to cascade.
16
+ #
17
+ # 2. The obvious alternative -- calling python-publish.yml's job directly via
18
+ # workflow_call instead of dispatching it as a separate run -- avoids (1)
19
+ # entirely, but breaks PyPI's OIDC trusted publishing: PyPI explicitly
20
+ # does not support trusted publishing from reusable/called workflows (the
21
+ # token's claims show a "reusable workflow" parent chain that PyPI's
22
+ # trusted-publisher matching rejects). So python-publish.yml must stay a
23
+ # plain, directly-triggered top-level workflow -- no workflow_call trigger
24
+ # on it, and nothing here should invoke it as `uses:`.
25
+
26
+ name: Auto Release
27
+
28
+ on:
29
+ workflow_run:
30
+ workflows: ["Python Test"]
31
+ types: [completed]
32
+
33
+ permissions:
34
+ contents: read
35
+
36
+ jobs:
37
+ release:
38
+ if: >
39
+ github.event.workflow_run.conclusion == 'success' &&
40
+ github.event.workflow_run.event == 'push' &&
41
+ github.event.workflow_run.head_branch == 'main'
42
+ runs-on: ubuntu-latest
43
+ permissions:
44
+ contents: write
45
+ actions: write
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ ref: ${{ github.event.workflow_run.head_sha }}
50
+
51
+ - name: Read version from pyproject.toml
52
+ id: version
53
+ run: |
54
+ version=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/')
55
+ echo "version=$version" >> "$GITHUB_OUTPUT"
56
+
57
+ - name: Skip if this version is already on PyPI
58
+ id: check
59
+ run: |
60
+ version="${{ steps.version.outputs.version }}"
61
+ if curl -fsS https://pypi.org/pypi/pgdevkit/json | jq -e --arg v "$version" '.releases[$v] != null' >/dev/null; then
62
+ echo "published=true" >> "$GITHUB_OUTPUT"
63
+ else
64
+ echo "published=false" >> "$GITHUB_OUTPUT"
65
+ fi
66
+
67
+ - name: Create tag and GitHub release, if missing
68
+ if: steps.check.outputs.published == 'false'
69
+ env:
70
+ GH_TOKEN: ${{ github.token }}
71
+ run: |
72
+ if ! gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
73
+ gh release create "v${{ steps.version.outputs.version }}" \
74
+ --title "v${{ steps.version.outputs.version }}" \
75
+ --target "${{ github.event.workflow_run.head_sha }}" \
76
+ --generate-notes
77
+ fi
78
+
79
+ - name: Dispatch PyPI publish
80
+ if: steps.check.outputs.published == 'false'
81
+ env:
82
+ GH_TOKEN: ${{ github.token }}
83
+ run: gh workflow run python-publish.yml --ref main
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.8
3
+ Version: 0.4.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -79,6 +79,62 @@ which parses/introspects/diffs like any other column type; see
79
79
  handled on the CRUD side (write-side serialization only, no auto-parsing on
80
80
  read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
81
81
 
82
+ ### Area tagging and filtering
83
+
84
+ Any migration file or `database/` code file can declare one or more areas by
85
+ starting with a `-- area:` comment:
86
+
87
+ ```sql
88
+ -- area: billing
89
+ CREATE TABLE billing.invoices (id int primary key);
90
+ ```
91
+
92
+ A file can declare more than one area, either comma-separated on one line
93
+ (`-- area: billing, reporting`) or across several `-- area:` lines — the
94
+ declared areas union. The directive is only recognized in the file's leading
95
+ comment block (blank lines and `--` comments at the very top, stopping at the
96
+ first real statement); a `-- area:` comment later in the file doesn't count.
97
+ A file with no directive is untagged, and untagged files are treated as
98
+ shared/common.
99
+
100
+ `pgdb compare`, `pgdb migrate check`, and `pgdb migrate apply` all accept:
101
+
102
+ - `--area NAME` (repeatable) — restrict to files declaring one of the given
103
+ areas, **plus every untagged file** (untagged files always stay in scope).
104
+ - `--exclude-area NAME` (repeatable) — drop files declaring one of the given
105
+ areas; untagged files are never dropped by this.
106
+
107
+ Both can be combined; a file matching both an included and an excluded area
108
+ is excluded. Passing neither option applies no filtering (the default,
109
+ unchanged behavior).
110
+
111
+ ```bash
112
+ pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
113
+ pgdb compare path/to/database/ --url ... --exclude-area reporting
114
+ ```
115
+
116
+ `compare`'s default report (no `--report-extra-db`) only checks that the
117
+ filtered scripts exist correctly in the DB, so it composes safely with area
118
+ filtering. Passing `--report-extra-db` together with an area filter also
119
+ reports every DB object outside the filtered area(s) as "missing in
120
+ scripts" — since the live database has no concept of areas, only the
121
+ scripts side is filtered — so treat that combination's "missing in scripts"
122
+ results with that in mind (the CLI prints a warning when you combine them).
123
+
124
+ `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area`: it
125
+ diffs the full database against scripts to find genuinely untracked
126
+ objects, so narrowing the scripts side by area would make every object
127
+ tracked only under a different area look "missing" too — and `--write`
128
+ would then reconstruct a duplicate file for something that already exists.
129
+
130
+ `pgdevkit.areas` exposes the same logic for scripting:
131
+ `parse_areas`/`file_areas` read a file's declared areas, and
132
+ `area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
133
+ `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
134
+ `pgdevkit.parser.parse_directory` take the same `areas`/`exclude_areas`
135
+ keyword arguments (`pgdevkit.fetch_missing.find_missing_objects` doesn't,
136
+ for the reason above).
137
+
82
138
  ## `pgdb testdb`
83
139
 
84
140
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -259,3 +315,29 @@ async with pool.connection() as con:
259
315
  widget = await pg_retrieve(con, Widget, {"id": 1})
260
316
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
261
317
  ```
318
+
319
+ ## Releasing
320
+
321
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
322
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
323
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
324
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
325
+ released, e.g. a merge that didn't touch the version), and dispatches
326
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
327
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
328
+ this (see the comments at the top of `auto-release.yml` for the full
329
+ reasoning, since both were hit and confirmed the hard way):
330
+
331
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
332
+ other workflows' `release: published` listeners (an anti-recursion
333
+ safeguard) — `workflow_dispatch` is the documented exception, so
334
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
335
+ run`) instead of relying on the release to cascade into it.
336
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
337
+ top-level workflow rather than something `auto-release.yml` calls via
338
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
339
+ reusable/called workflows and silently rejects the token in that shape.
340
+
341
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
342
+ still works as a manual fallback if you ever need to re-publish a version
343
+ without going through `auto-release.yml`.
@@ -58,6 +58,62 @@ which parses/introspects/diffs like any other column type; see
58
58
  handled on the CRUD side (write-side serialization only, no auto-parsing on
59
59
  read — `mssql-python` doesn't distinguish `json` columns from `nvarchar`).
60
60
 
61
+ ### Area tagging and filtering
62
+
63
+ Any migration file or `database/` code file can declare one or more areas by
64
+ starting with a `-- area:` comment:
65
+
66
+ ```sql
67
+ -- area: billing
68
+ CREATE TABLE billing.invoices (id int primary key);
69
+ ```
70
+
71
+ A file can declare more than one area, either comma-separated on one line
72
+ (`-- area: billing, reporting`) or across several `-- area:` lines — the
73
+ declared areas union. The directive is only recognized in the file's leading
74
+ comment block (blank lines and `--` comments at the very top, stopping at the
75
+ first real statement); a `-- area:` comment later in the file doesn't count.
76
+ A file with no directive is untagged, and untagged files are treated as
77
+ shared/common.
78
+
79
+ `pgdb compare`, `pgdb migrate check`, and `pgdb migrate apply` all accept:
80
+
81
+ - `--area NAME` (repeatable) — restrict to files declaring one of the given
82
+ areas, **plus every untagged file** (untagged files always stay in scope).
83
+ - `--exclude-area NAME` (repeatable) — drop files declaring one of the given
84
+ areas; untagged files are never dropped by this.
85
+
86
+ Both can be combined; a file matching both an included and an excluded area
87
+ is excluded. Passing neither option applies no filtering (the default,
88
+ unchanged behavior).
89
+
90
+ ```bash
91
+ pgdb migrate apply path/to/database/_migration_scripts --url ... --area billing
92
+ pgdb compare path/to/database/ --url ... --exclude-area reporting
93
+ ```
94
+
95
+ `compare`'s default report (no `--report-extra-db`) only checks that the
96
+ filtered scripts exist correctly in the DB, so it composes safely with area
97
+ filtering. Passing `--report-extra-db` together with an area filter also
98
+ reports every DB object outside the filtered area(s) as "missing in
99
+ scripts" — since the live database has no concept of areas, only the
100
+ scripts side is filtered — so treat that combination's "missing in scripts"
101
+ results with that in mind (the CLI prints a warning when you combine them).
102
+
103
+ `pgdb fetch-missing` deliberately has **no** `--area`/`--exclude-area`: it
104
+ diffs the full database against scripts to find genuinely untracked
105
+ objects, so narrowing the scripts side by area would make every object
106
+ tracked only under a different area look "missing" too — and `--write`
107
+ would then reconstruct a duplicate file for something that already exists.
108
+
109
+ `pgdevkit.areas` exposes the same logic for scripting:
110
+ `parse_areas`/`file_areas` read a file's declared areas, and
111
+ `area_allowed`/`filter_by_area` apply the `only`/`exclude` semantics above.
112
+ `pgdevkit.migrate.list_migration_files`/`pending_migrations` and
113
+ `pgdevkit.parser.parse_directory` take the same `areas`/`exclude_areas`
114
+ keyword arguments (`pgdevkit.fetch_missing.find_missing_objects` doesn't,
115
+ for the reason above).
116
+
61
117
  ## `pgdb testdb`
62
118
 
63
119
  Manages a single shared, Podman-backed Postgres container for local tests
@@ -238,3 +294,29 @@ async with pool.connection() as con:
238
294
  widget = await pg_retrieve(con, Widget, {"id": 1})
239
295
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
240
296
  ```
297
+
298
+ ## Releasing
299
+
300
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
301
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
302
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
303
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
304
+ released, e.g. a merge that didn't touch the version), and dispatches
305
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
306
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
307
+ this (see the comments at the top of `auto-release.yml` for the full
308
+ reasoning, since both were hit and confirmed the hard way):
309
+
310
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
311
+ other workflows' `release: published` listeners (an anti-recursion
312
+ safeguard) — `workflow_dispatch` is the documented exception, so
313
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
314
+ run`) instead of relying on the release to cascade into it.
315
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
316
+ top-level workflow rather than something `auto-release.yml` calls via
317
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
318
+ reusable/called workflows and silently rejects the token in that shape.
319
+
320
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
321
+ still works as a manual fallback if you ever need to re-publish a version
322
+ without going through `auto-release.yml`.
@@ -0,0 +1,64 @@
1
+ """Optional `-- area: NAME[, NAME...]` tag recognized in the leading comment
2
+ block of a migration file or a `database/` code file (blank lines and `--`
3
+ comments at the very top, stopping at the first real statement — like a file
4
+ header). A file may declare more than one area, either as a comma-separated
5
+ list on one line or across several `-- area:` lines (the areas union).
6
+
7
+ A file with no such directive is "untagged" and is treated as shared/common:
8
+ `only` filters always keep untagged files, and `exclude` filters never drop
9
+ them — only a file that explicitly declares an excluded area is dropped.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ _AREA_LINE = re.compile(r"^\s*--\s*area\s*:\s*(.+?)\s*$", re.IGNORECASE)
18
+
19
+
20
+ def parse_areas(content: str) -> frozenset[str]:
21
+ """Area names declared in `content`'s leading comment block."""
22
+ areas: set[str] = set()
23
+ for line in content.splitlines():
24
+ stripped = line.strip()
25
+ if not stripped:
26
+ continue
27
+ if not stripped.startswith("--"):
28
+ break
29
+ m = _AREA_LINE.match(stripped)
30
+ if m:
31
+ areas.update(a.strip() for a in m.group(1).split(",") if a.strip())
32
+ return frozenset(areas)
33
+
34
+
35
+ def file_areas(path: Path) -> frozenset[str]:
36
+ """Area names declared in the file at `path`."""
37
+ return parse_areas(path.read_text(encoding="utf-8"))
38
+
39
+
40
+ def area_allowed(
41
+ areas: frozenset[str],
42
+ *,
43
+ only: frozenset[str] | None = None,
44
+ exclude: frozenset[str] | None = None,
45
+ ) -> bool:
46
+ """Whether a file that declares `areas` passes an `only`/`exclude` filter."""
47
+ if exclude and areas & exclude:
48
+ return False
49
+ if only and areas and not (areas & only):
50
+ return False
51
+ return True
52
+
53
+
54
+ def filter_by_area(
55
+ paths: list[Path],
56
+ *,
57
+ only: frozenset[str] | None = None,
58
+ exclude: frozenset[str] | None = None,
59
+ ) -> list[Path]:
60
+ """`paths` restricted by an `only`/`exclude` area filter. Returns `paths`
61
+ unchanged (no file reads) when neither filter is set."""
62
+ if not only and not exclude:
63
+ return paths
64
+ return [p for p in paths if area_allowed(file_areas(p), only=only, exclude=exclude)]
@@ -23,6 +23,17 @@ app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
23
23
  console = Console()
24
24
  err_console = Console(stderr=True)
25
25
 
26
+ _AREA_OPTION = typer.Option(
27
+ [], "--area", help="Restrict to files declaring this area (repeatable); untagged files always stay in scope"
28
+ )
29
+ _EXCLUDE_AREA_OPTION = typer.Option(
30
+ [], "--exclude-area", help="Skip files declaring this area (repeatable); untagged files are never excluded"
31
+ )
32
+
33
+
34
+ def _as_area_set(values: list[str]) -> frozenset[str] | None:
35
+ return frozenset(values) if values else None
36
+
26
37
  testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
27
38
  app.add_typer(testdb_app, name="testdb")
28
39
 
@@ -46,12 +57,20 @@ def compare(
46
57
  ),
47
58
  report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
48
59
  dialect: str = typer.Option("postgres", "--dialect", help="postgres (default) or mssql"),
60
+ area: list[str] = _AREA_OPTION,
61
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
49
62
  scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
50
63
  ) -> None:
51
64
  """Compare SQL scripts to a live database and report differences."""
52
65
  if not scripts_dir.is_dir():
53
66
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
54
67
  raise typer.Exit(2)
68
+ if report_extra_db and (area or exclude_area):
69
+ console.print(
70
+ "[yellow]⚠[/yellow] --report-extra-db with --area/--exclude-area will report every DB object "
71
+ "outside the filtered area(s) as \"missing in scripts\", since the live database has no concept "
72
+ "of areas — only the scripts side is filtered."
73
+ )
55
74
 
56
75
  try:
57
76
  conninfo = build_conninfo(
@@ -71,7 +90,9 @@ def compare(
71
90
  raise typer.Exit(2)
72
91
 
73
92
  with console.status("Parsing SQL scripts..."):
74
- scripts_schema = parse_directory(scripts_dir, dialect=backend.dialect)
93
+ scripts_schema = parse_directory(
94
+ scripts_dir, dialect=backend.dialect, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
95
+ )
75
96
 
76
97
  with console.status("Introspecting database..."):
77
98
  db_schema = backend.introspect(conninfo)
@@ -110,7 +131,11 @@ def fetch_missing(
110
131
  only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
111
132
  ) -> None:
112
133
  """Find tables/views/functions that exist in the database but aren't
113
- tracked under scripts_dir, and reverse-engineer their DDL into new files."""
134
+ tracked under scripts_dir, and reverse-engineer their DDL into new files.
135
+
136
+ No --area/--exclude-area here (unlike compare/migrate): this diffs the
137
+ full live database against scripts, so filtering the scripts side by
138
+ area would misreport objects tracked under other areas as missing."""
114
139
  if not scripts_dir.is_dir():
115
140
  err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
116
141
  raise typer.Exit(2)
@@ -245,6 +270,8 @@ def migrate_check(
245
270
  help="schema.table recording applied migrations "
246
271
  "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
247
272
  ),
273
+ area: list[str] = _AREA_OPTION,
274
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
248
275
  ) -> None:
249
276
  """List which migration files under migrations_dir are applied vs. pending."""
250
277
  if not migrations_dir.is_dir():
@@ -253,7 +280,9 @@ def migrate_check(
253
280
 
254
281
  conninfo = build_conninfo(url, entra_user)
255
282
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
256
- local_files = migrate.list_migration_files(migrations_dir)
283
+ local_files = migrate.list_migration_files(
284
+ migrations_dir, areas=_as_area_set(area), exclude_areas=_as_area_set(exclude_area)
285
+ )
257
286
  try:
258
287
  applied = migrate.applied_migrations(conninfo, tracking_table)
259
288
  except migrate.TrackingTableMissing:
@@ -292,6 +321,8 @@ def migrate_apply(
292
321
  ),
293
322
  ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
294
323
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
324
+ area: list[str] = _AREA_OPTION,
325
+ exclude_area: list[str] = _EXCLUDE_AREA_OPTION,
295
326
  ) -> None:
296
327
  """Apply pending migration files, in filename order, tracking each in tracking_table."""
297
328
  if not migrations_dir.is_dir():
@@ -300,6 +331,7 @@ def migrate_apply(
300
331
 
301
332
  conninfo = build_conninfo(url, entra_user)
302
333
  tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
334
+ areas, exclude_areas = _as_area_set(area), _as_area_set(exclude_area)
303
335
  target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
304
336
  if not yes:
305
337
  typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
@@ -308,12 +340,14 @@ def migrate_apply(
308
340
  targets = [migrations_dir / file]
309
341
  else:
310
342
  try:
311
- targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table)
343
+ targets = migrate.pending_migrations(
344
+ migrations_dir, conninfo, tracking_table, areas=areas, exclude_areas=exclude_areas
345
+ )
312
346
  except migrate.TrackingTableMissing:
313
347
  err_console.print(
314
348
  f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
315
349
  )
316
- targets = migrate.list_migration_files(migrations_dir)
350
+ targets = migrate.list_migration_files(migrations_dir, areas=areas, exclude_areas=exclude_areas)
317
351
 
318
352
  if not targets:
319
353
  console.print("No pending migrations.")
@@ -50,7 +50,12 @@ def layer_folder_for(scripts_dir: Path, schema: str) -> Path:
50
50
 
51
51
  def find_missing_objects(scripts_dir: Path, conninfo: str) -> list[MissingObject]:
52
52
  """Tables, views, and functions that exist in the live database but
53
- aren't tracked as .sql files under scripts_dir."""
53
+ aren't tracked as .sql files under scripts_dir.
54
+
55
+ Deliberately not area-filterable: this diffs the *full* database against
56
+ scripts with report_extra_db=True, so narrowing the scripts side to one
57
+ area would make every object tracked only under a different area look
58
+ "missing" too — and --write would reconstruct a duplicate file for it."""
54
59
  scripts = parse_directory(scripts_dir)
55
60
  db = introspect_db(conninfo)
56
61
  diffs = compute_diff(scripts, db, report_extra_db=True)
@@ -18,6 +18,8 @@ import sqlglot
18
18
  from psycopg import errors as pg_errors
19
19
  from psycopg import sql as pg_sql
20
20
 
21
+ from .areas import filter_by_area
22
+
21
23
  _IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
22
24
  _DEFAULT_TRACKING_TABLE = "public.schema_migrations"
23
25
 
@@ -274,8 +276,14 @@ def already_fully_applied(conninfo: str, path: Path) -> bool:
274
276
  return all(_target_exists(con, cast(tuple[str, ...], t)) for t in targets)
275
277
 
276
278
 
277
- def list_migration_files(migrations_dir: Path) -> list[Path]:
278
- return sorted(migrations_dir.glob("*.sql"))
279
+ def list_migration_files(
280
+ migrations_dir: Path,
281
+ *,
282
+ areas: frozenset[str] | None = None,
283
+ exclude_areas: frozenset[str] | None = None,
284
+ ) -> list[Path]:
285
+ files = sorted(migrations_dir.glob("*.sql"))
286
+ return filter_by_area(files, only=areas, exclude=exclude_areas)
279
287
 
280
288
 
281
289
  def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
@@ -291,9 +299,17 @@ def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[da
291
299
  return {r[0]: (r[1], r[2]) for r in rows}
292
300
 
293
301
 
294
- def pending_migrations(migrations_dir: Path, conninfo: str, tracking_table: str) -> list[Path]:
302
+ def pending_migrations(
303
+ migrations_dir: Path,
304
+ conninfo: str,
305
+ tracking_table: str,
306
+ *,
307
+ areas: frozenset[str] | None = None,
308
+ exclude_areas: frozenset[str] | None = None,
309
+ ) -> list[Path]:
295
310
  applied = applied_migrations(conninfo, tracking_table)
296
- return [p for p in list_migration_files(migrations_dir) if p.name not in applied]
311
+ files = list_migration_files(migrations_dir, areas=areas, exclude_areas=exclude_areas)
312
+ return [p for p in files if p.name not in applied]
297
313
 
298
314
 
299
315
  def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import re
4
5
  import logging
5
6
  from pathlib import Path
@@ -7,6 +8,7 @@ from pathlib import Path
7
8
  import sqlglot
8
9
  import sqlglot.expressions as exp
9
10
 
11
+ from .areas import area_allowed, parse_areas
10
12
  from .dialect import Dialect, POSTGRES, resolve_dialect
11
13
  from .models import (
12
14
  ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
@@ -39,18 +41,38 @@ _DO_COMPOSITE = re.compile(
39
41
  )
40
42
 
41
43
 
42
- def parse_directory(scripts_dir: Path, *, dialect: str | Dialect = "postgres") -> DatabaseSchema:
44
+ def _iter_sql_files(scripts_dir: Path):
45
+ for root, dirs, files in os.walk(scripts_dir):
46
+ dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
47
+ for name in files:
48
+ if name.endswith(".sql"):
49
+ yield Path(root) / name
50
+
51
+
52
+ def parse_directory(
53
+ scripts_dir: Path,
54
+ *,
55
+ dialect: str | Dialect = "postgres",
56
+ areas: frozenset[str] | None = None,
57
+ exclude_areas: frozenset[str] | None = None,
58
+ ) -> DatabaseSchema:
43
59
  resolved = resolve_dialect(dialect)
44
60
  db_schema = DatabaseSchema()
45
- for sql_file in sorted(scripts_dir.rglob("*.sql")):
46
- if IGNORED_DIR_NAMES.intersection(sql_file.relative_to(scripts_dir).parts[:-1]):
61
+ for sql_file in sorted(_iter_sql_files(scripts_dir)):
62
+ # Read once and reuse for both the area check and parsing, rather than
63
+ # filtering the file list up front (which would need its own read).
64
+ content = sql_file.read_text(encoding="utf-8")
65
+ if (areas or exclude_areas) and not area_allowed(parse_areas(content), only=areas, exclude=exclude_areas):
47
66
  continue
48
- _parse_file(sql_file, db_schema, resolved)
67
+ _parse_file(sql_file, db_schema, resolved, content=content)
49
68
  return db_schema
50
69
 
51
70
 
52
- def _parse_file(path: Path, db_schema: DatabaseSchema, dialect: Dialect = POSTGRES) -> None:
53
- content = path.read_text(encoding="utf-8")
71
+ def _parse_file(
72
+ path: Path, db_schema: DatabaseSchema, dialect: Dialect = POSTGRES, content: str | None = None
73
+ ) -> None:
74
+ if content is None:
75
+ content = path.read_text(encoding="utf-8")
54
76
  try:
55
77
  exprs = sqlglot.parse(content, dialect=dialect.sqlglot_name, error_level=sqlglot.ErrorLevel.WARN)
56
78
  except Exception as e:
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.3.8"
14
+ version = "0.4.0"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pgdevkit.areas import area_allowed, file_areas, filter_by_area, parse_areas
6
+
7
+
8
+ class TestParseAreas:
9
+ def test_no_directive_is_untagged(self):
10
+ assert parse_areas("CREATE TABLE t (id int);\n") == frozenset()
11
+
12
+ def test_single_area(self):
13
+ sql = "-- area: billing\nCREATE TABLE t (id int);\n"
14
+ assert parse_areas(sql) == frozenset({"billing"})
15
+
16
+ def test_comma_separated_areas(self):
17
+ sql = "-- area: billing, reporting\nCREATE TABLE t (id int);\n"
18
+ assert parse_areas(sql) == frozenset({"billing", "reporting"})
19
+
20
+ def test_repeated_lines_union(self):
21
+ sql = "-- area: billing\n-- area: reporting\nCREATE TABLE t (id int);\n"
22
+ assert parse_areas(sql) == frozenset({"billing", "reporting"})
23
+
24
+ def test_case_insensitive_directive(self):
25
+ sql = "-- Area: billing\nCREATE TABLE t (id int);\n"
26
+ assert parse_areas(sql) == frozenset({"billing"})
27
+
28
+ def test_blank_lines_before_directive_are_skipped(self):
29
+ sql = "\n\n-- area: billing\nCREATE TABLE t (id int);\n"
30
+ assert parse_areas(sql) == frozenset({"billing"})
31
+
32
+ def test_directive_after_other_leading_comments_still_counts(self):
33
+ sql = "-- Copyright 2026\n-- area: billing\nCREATE TABLE t (id int);\n"
34
+ assert parse_areas(sql) == frozenset({"billing"})
35
+
36
+ def test_directive_past_leading_comment_block_is_ignored(self):
37
+ sql = "CREATE TABLE t (id int);\n-- area: billing\n"
38
+ assert parse_areas(sql) == frozenset()
39
+
40
+ def test_directive_stops_at_first_blank_then_statement(self):
41
+ # A directive after a blank line inside the header still counts (blank
42
+ # lines don't end the header), but nothing after the first real
43
+ # statement does, even inside a later comment.
44
+ sql = "-- area: billing\n\nCREATE TABLE t (id int);\n-- area: reporting\n"
45
+ assert parse_areas(sql) == frozenset({"billing"})
46
+
47
+
48
+ class TestFileAreas:
49
+ def test_reads_from_disk(self, tmp_path: Path):
50
+ f = tmp_path / "001_thing.sql"
51
+ f.write_text("-- area: billing\nCREATE TABLE t (id int);\n", encoding="utf-8")
52
+ assert file_areas(f) == frozenset({"billing"})
53
+
54
+
55
+ class TestAreaAllowed:
56
+ def test_no_filters_always_allowed(self):
57
+ assert area_allowed(frozenset({"billing"})) is True
58
+ assert area_allowed(frozenset()) is True
59
+
60
+ def test_only_filter_untagged_always_passes(self):
61
+ assert area_allowed(frozenset(), only=frozenset({"billing"})) is True
62
+
63
+ def test_only_filter_matching_area_passes(self):
64
+ assert area_allowed(frozenset({"billing"}), only=frozenset({"billing"})) is True
65
+
66
+ def test_only_filter_non_matching_area_fails(self):
67
+ assert area_allowed(frozenset({"reporting"}), only=frozenset({"billing"})) is False
68
+
69
+ def test_exclude_filter_untagged_never_dropped(self):
70
+ assert area_allowed(frozenset(), exclude=frozenset({"billing"})) is True
71
+
72
+ def test_exclude_filter_matching_area_dropped(self):
73
+ assert area_allowed(frozenset({"billing"}), exclude=frozenset({"billing"})) is False
74
+
75
+ def test_exclude_filter_non_matching_area_passes(self):
76
+ assert area_allowed(frozenset({"reporting"}), exclude=frozenset({"billing"})) is True
77
+
78
+ def test_only_and_exclude_combined_exclude_wins(self):
79
+ areas = frozenset({"billing"})
80
+ assert area_allowed(areas, only=frozenset({"billing"}), exclude=frozenset({"billing"})) is False
81
+
82
+
83
+ class TestFilterByArea:
84
+ def test_no_filters_returns_paths_unchanged(self, tmp_path: Path):
85
+ paths = [tmp_path / "a.sql", tmp_path / "b.sql"]
86
+ assert filter_by_area(paths) == paths
87
+
88
+ def test_only_keeps_matching_and_untagged(self, tmp_path: Path):
89
+ tagged = tmp_path / "billing.sql"
90
+ tagged.write_text("-- area: billing\nselect 1;\n", encoding="utf-8")
91
+ other = tmp_path / "reporting.sql"
92
+ other.write_text("-- area: reporting\nselect 1;\n", encoding="utf-8")
93
+ untagged = tmp_path / "common.sql"
94
+ untagged.write_text("select 1;\n", encoding="utf-8")
95
+
96
+ result = filter_by_area([tagged, other, untagged], only=frozenset({"billing"}))
97
+ assert set(result) == {tagged, untagged}
98
+
99
+ def test_exclude_drops_matching_but_keeps_untagged(self, tmp_path: Path):
100
+ tagged = tmp_path / "billing.sql"
101
+ tagged.write_text("-- area: billing\nselect 1;\n", encoding="utf-8")
102
+ untagged = tmp_path / "common.sql"
103
+ untagged.write_text("select 1;\n", encoding="utf-8")
104
+
105
+ result = filter_by_area([tagged, untagged], exclude=frozenset({"billing"}))
106
+ assert result == [untagged]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pgdevkit.migrate import list_migration_files
6
+
7
+
8
+ def _write(dir: Path, name: str, content: str) -> Path:
9
+ p = dir / name
10
+ p.write_text(content, encoding="utf-8")
11
+ return p
12
+
13
+
14
+ class TestListMigrationFilesAreaFiltering:
15
+ def test_no_filters_lists_everything(self, tmp_path: Path):
16
+ a = _write(tmp_path, "001_a.sql", "-- area: billing\nselect 1;\n")
17
+ b = _write(tmp_path, "002_b.sql", "select 1;\n")
18
+ assert list_migration_files(tmp_path) == sorted([a, b])
19
+
20
+ def test_areas_filter_keeps_matching_and_untagged(self, tmp_path: Path):
21
+ billing = _write(tmp_path, "001_billing.sql", "-- area: billing\nselect 1;\n")
22
+ reporting = _write(tmp_path, "002_reporting.sql", "-- area: reporting\nselect 1;\n")
23
+ common = _write(tmp_path, "003_common.sql", "select 1;\n")
24
+
25
+ result = list_migration_files(tmp_path, areas=frozenset({"billing"}))
26
+ assert set(result) == {billing, common}
27
+ assert reporting not in result
28
+
29
+ def test_exclude_areas_drops_matching_but_keeps_untagged(self, tmp_path: Path):
30
+ billing = _write(tmp_path, "001_billing.sql", "-- area: billing\nselect 1;\n")
31
+ common = _write(tmp_path, "002_common.sql", "select 1;\n")
32
+
33
+ result = list_migration_files(tmp_path, exclude_areas=frozenset({"billing"}))
34
+ assert result == [common]
35
+ assert billing not in result
36
+
37
+ def test_multiple_areas_on_one_file(self, tmp_path: Path):
38
+ multi = _write(tmp_path, "001_multi.sql", "-- area: billing, reporting\nselect 1;\n")
39
+
40
+ assert list_migration_files(tmp_path, areas=frozenset({"reporting"})) == [multi]
41
+ assert list_migration_files(tmp_path, exclude_areas=frozenset({"billing"})) == []
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pgdevkit.parser import parse_directory
6
+
7
+
8
+ def _write(dir: Path, name: str, content: str) -> Path:
9
+ p = dir / name
10
+ p.write_text(content, encoding="utf-8")
11
+ return p
12
+
13
+
14
+ class TestParseDirectoryAreaFiltering:
15
+ def test_no_filters_parses_everything(self, tmp_path: Path):
16
+ _write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
17
+ _write(tmp_path, "common.sql", "CREATE TABLE public.b (id int);\n")
18
+ schema = parse_directory(tmp_path)
19
+ assert set(schema.tables) == {"public.a", "public.b"}
20
+
21
+ def test_areas_filter_keeps_matching_and_untagged(self, tmp_path: Path):
22
+ _write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
23
+ _write(tmp_path, "reporting.sql", "-- area: reporting\nCREATE TABLE public.b (id int);\n")
24
+ _write(tmp_path, "common.sql", "CREATE TABLE public.c (id int);\n")
25
+
26
+ schema = parse_directory(tmp_path, areas=frozenset({"billing"}))
27
+ assert set(schema.tables) == {"public.a", "public.c"}
28
+
29
+ def test_exclude_areas_drops_matching_but_keeps_untagged(self, tmp_path: Path):
30
+ _write(tmp_path, "billing.sql", "-- area: billing\nCREATE TABLE public.a (id int);\n")
31
+ _write(tmp_path, "common.sql", "CREATE TABLE public.c (id int);\n")
32
+
33
+ schema = parse_directory(tmp_path, exclude_areas=frozenset({"billing"}))
34
+ assert set(schema.tables) == {"public.c"}
@@ -313,7 +313,7 @@ wheels = [
313
313
 
314
314
  [[package]]
315
315
  name = "pgdevkit"
316
- version = "0.3.8"
316
+ version = "0.4.0"
317
317
  source = { editable = "." }
318
318
  dependencies = [
319
319
  { name = "docker" },
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