django-data-shape 0.1.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 (57) hide show
  1. django_data_shape-0.1.0/.github/CODEOWNERS +1 -0
  2. django_data_shape-0.1.0/.github/SECURITY.md +9 -0
  3. django_data_shape-0.1.0/.github/dependabot.yml +26 -0
  4. django_data_shape-0.1.0/.github/workflows/release.yml +137 -0
  5. django_data_shape-0.1.0/.github/workflows/tests.yml +433 -0
  6. django_data_shape-0.1.0/.github/workflows/upstream-drift.yml +106 -0
  7. django_data_shape-0.1.0/.gitignore +639 -0
  8. django_data_shape-0.1.0/.pre-commit-config.yaml +67 -0
  9. django_data_shape-0.1.0/CHANGELOG.md +53 -0
  10. django_data_shape-0.1.0/CLAUDE.md +160 -0
  11. django_data_shape-0.1.0/LICENSE +21 -0
  12. django_data_shape-0.1.0/Makefile +97 -0
  13. django_data_shape-0.1.0/PKG-INFO +110 -0
  14. django_data_shape-0.1.0/README.md +70 -0
  15. django_data_shape-0.1.0/django_data_shape/__init__.py +33 -0
  16. django_data_shape-0.1.0/django_data_shape/build.py +144 -0
  17. django_data_shape-0.1.0/django_data_shape/build_result.py +27 -0
  18. django_data_shape-0.1.0/django_data_shape/distributions/__init__.py +9 -0
  19. django_data_shape-0.1.0/django_data_shape/distributions/constant.py +26 -0
  20. django_data_shape-0.1.0/django_data_shape/distributions/distribution.py +24 -0
  21. django_data_shape-0.1.0/django_data_shape/distributions/sequential.py +31 -0
  22. django_data_shape-0.1.0/django_data_shape/distributions/skew.py +71 -0
  23. django_data_shape-0.1.0/django_data_shape/distributions/uniform.py +50 -0
  24. django_data_shape-0.1.0/django_data_shape/generate_rows.py +43 -0
  25. django_data_shape-0.1.0/django_data_shape/invalid_shape.py +18 -0
  26. django_data_shape-0.1.0/django_data_shape/py.typed +0 -0
  27. django_data_shape-0.1.0/django_data_shape/require_postgres.py +46 -0
  28. django_data_shape-0.1.0/django_data_shape/shape.py +42 -0
  29. django_data_shape-0.1.0/django_data_shape/shape_not_empty.py +17 -0
  30. django_data_shape-0.1.0/django_data_shape/table.py +207 -0
  31. django_data_shape-0.1.0/django_data_shape/table_result.py +18 -0
  32. django_data_shape-0.1.0/django_data_shape/unsupported_backend.py +18 -0
  33. django_data_shape-0.1.0/django_data_shape/utils.py +45 -0
  34. django_data_shape-0.1.0/django_data_shape/version.py +7 -0
  35. django_data_shape-0.1.0/docs/index.md +112 -0
  36. django_data_shape-0.1.0/docs/reference.md +29 -0
  37. django_data_shape-0.1.0/mkdocs.yml +73 -0
  38. django_data_shape-0.1.0/pyproject.toml +180 -0
  39. django_data_shape-0.1.0/scripts/release-publish.sh +246 -0
  40. django_data_shape-0.1.0/tests/__init__.py +0 -0
  41. django_data_shape-0.1.0/tests/conftest.py +3 -0
  42. django_data_shape-0.1.0/tests/conftest_settings.py +61 -0
  43. django_data_shape-0.1.0/tests/distributions/__init__.py +0 -0
  44. django_data_shape-0.1.0/tests/distributions/test_constant.py +15 -0
  45. django_data_shape-0.1.0/tests/distributions/test_sequential.py +34 -0
  46. django_data_shape-0.1.0/tests/distributions/test_skew.py +67 -0
  47. django_data_shape-0.1.0/tests/distributions/test_uniform.py +57 -0
  48. django_data_shape-0.1.0/tests/test_build.py +231 -0
  49. django_data_shape-0.1.0/tests/test_generate_rows.py +56 -0
  50. django_data_shape-0.1.0/tests/test_require_postgres.py +67 -0
  51. django_data_shape-0.1.0/tests/test_shape.py +42 -0
  52. django_data_shape-0.1.0/tests/test_table.py +178 -0
  53. django_data_shape-0.1.0/tests/test_utils.py +50 -0
  54. django_data_shape-0.1.0/tests/test_version.py +12 -0
  55. django_data_shape-0.1.0/tests/testapp/__init__.py +0 -0
  56. django_data_shape-0.1.0/tests/testapp/models.py +114 -0
  57. django_data_shape-0.1.0/uv.lock +1467 -0
@@ -0,0 +1 @@
1
+ * @Artui
@@ -0,0 +1,9 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Please report security issues **privately** via GitHub's
6
+ [Report a vulnerability](../../security/advisories/new) button rather than opening a public issue.
7
+ I aim to acknowledge reports within 72 hours.
8
+
9
+ Supported: the latest released version on the default branch.
@@ -0,0 +1,26 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ # Dependabot already holds a new version for 3 days before proposing it; a
8
+ # week is the more useful threshold. A compromised release is typically
9
+ # yanked within days, so the gap between "published" and "found" is exactly
10
+ # the window an automatic upgrade walks into. This costs nothing in
11
+ # responsiveness that matters: cooldown covers version updates only, and
12
+ # security advisories are proposed immediately regardless.
13
+ cooldown:
14
+ default-days: 7
15
+ groups:
16
+ actions:
17
+ patterns: ["*"]
18
+ - package-ecosystem: "uv"
19
+ directory: "/"
20
+ schedule:
21
+ interval: "weekly"
22
+ cooldown:
23
+ default-days: 7
24
+ groups:
25
+ python:
26
+ patterns: ["*"]
@@ -0,0 +1,137 @@
1
+ name: release
2
+
3
+ # Main-triggered: every merge to `main` runs `release-publish-prepare`, which
4
+ # short-circuits to a no-op unless the version in source has been bumped past
5
+ # the most recent `vX.Y.Z` tag. The previous tag-trigger flow has been removed
6
+ # — `make release-bump` + merge to main is now the only path.
7
+ on:
8
+ push:
9
+ branches: [main]
10
+
11
+ concurrency:
12
+ group: release-main
13
+ cancel-in-progress: false
14
+
15
+ # Actions are pinned to a commit SHA rather than a tag: a tag is mutable, and an
16
+ # action owner (or anyone who takes over the account) can silently repoint one at
17
+ # new code that then runs with this workflow's token. The trailing version comment
18
+ # is not decoration -- Dependabot reads it to know which release the SHA is, and
19
+ # rewrites both together when a newer one ships.
20
+ jobs:
21
+ release:
22
+ name: release
23
+ runs-on: ubuntu-latest
24
+ environment:
25
+ name: pypi
26
+ url: https://pypi.org/p/django-data-shape
27
+ permissions:
28
+ id-token: write # PyPI OIDC trusted publishing
29
+ contents: write # tag push + gh release create
30
+ # `release-publish-prepare` runs the suite before it builds anything, and
31
+ # this package's test settings default to Postgres on purpose (see
32
+ # tests/conftest_settings.py). Without a server here every release would
33
+ # fail at the test step -- a consequence of that default which would
34
+ # surface at the first real release rather than in CI.
35
+ services:
36
+ postgres:
37
+ image: postgres:17
38
+ env:
39
+ POSTGRES_USER: postgres
40
+ POSTGRES_PASSWORD: postgres
41
+ POSTGRES_DB: django_data_shape
42
+ ports:
43
+ - 5432:5432
44
+ options: >-
45
+ --health-cmd pg_isready
46
+ --health-interval 10s
47
+ --health-timeout 5s
48
+ --health-retries 5
49
+ env:
50
+ PGHOST: localhost
51
+ PGPORT: "5432"
52
+ PGUSER: postgres
53
+ PGPASSWORD: postgres
54
+ PGDATABASE: django_data_shape
55
+ steps:
56
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
+ with:
58
+ fetch-depth: 0
59
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
60
+ with:
61
+ enable-cache: true
62
+ - name: Install
63
+ run: uv sync --all-groups
64
+
65
+ - name: Prepare release (version check, test, build dist)
66
+ id: prepare
67
+ run: make release-publish-prepare
68
+
69
+ - name: Publish to PyPI
70
+ if: steps.prepare.outputs.released == 'true'
71
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
72
+ with:
73
+ # Idempotent on purpose. What gates a release is the git tag, not the
74
+ # index (see `prepare`), so re-running this job after a failure in a
75
+ # later step arrives here having already uploaded these files. Without
76
+ # this the upload is the step that makes that re-run impossible, and
77
+ # every recovery becomes manual — which is how one release lost its tag,
78
+ # its GitHub Release and its docs deploy to a single transient push
79
+ # failure, with the package already published.
80
+ # PyPI never lets a file be replaced, so skipping one cannot overwrite
81
+ # a published artefact.
82
+ skip-existing: true
83
+
84
+ - name: Tag + GitHub Release
85
+ if: steps.prepare.outputs.released == 'true'
86
+ env:
87
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
88
+ run: make release-publish-finalize
89
+
90
+ # On release commits we own the single gh-pages push: embed the
91
+ # coverage badge JSON into the docs build so mkdocs gh-deploy carries
92
+ # it alongside the site, and tests.yml's coverage-badge job
93
+ # short-circuits to avoid a second push (and a second built-in
94
+ # "pages build and deployment" run).
95
+ - name: Render coverage badge for the docs site
96
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
97
+ run: |
98
+ set -euo pipefail
99
+ # release-publish-prepare's pytest run wrote .coverage to the
100
+ # workspace root; convert to coverage.xml so the same parser
101
+ # we use in tests.yml can score it.
102
+ uv run coverage xml -o coverage.xml
103
+ python - <<'PY'
104
+ import json
105
+ import xml.etree.ElementTree as ET
106
+
107
+ tree = ET.parse("coverage.xml")
108
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
109
+ if pct >= 95:
110
+ color = "brightgreen"
111
+ elif pct >= 85:
112
+ color = "green"
113
+ elif pct >= 70:
114
+ color = "yellow"
115
+ else:
116
+ color = "red"
117
+ payload = {
118
+ "schemaVersion": 1,
119
+ "label": "coverage",
120
+ "message": f"{pct}%",
121
+ "color": color,
122
+ }
123
+ # docs/ is mkdocs' source dir; non-.md files are copied verbatim
124
+ # into site/, which lands at the gh-pages root after gh-deploy.
125
+ with open("docs/coverage.json", "w") as fh:
126
+ json.dump(payload, fh)
127
+ fh.write("\n")
128
+ print(json.dumps(payload))
129
+ PY
130
+
131
+ - name: Deploy docs + coverage badge to gh-pages
132
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
133
+ run: |
134
+ git config user.name 'github-actions[bot]'
135
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
136
+ DJANGO_SETTINGS_MODULE=tests.conftest_settings \
137
+ uv run mkdocs gh-deploy --force --clean --config-file mkdocs.yml
@@ -0,0 +1,433 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: tests-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ # Actions are pinned to a commit SHA rather than a tag: a tag is mutable, and an
13
+ # action owner (or anyone who takes over the account) can silently repoint one at
14
+ # new code that then runs with this workflow's token. The trailing version comment
15
+ # is not decoration -- Dependabot reads it to know which release the SHA is, and
16
+ # rewrites both together when a newer one ships.
17
+ jobs:
18
+ lint:
19
+ name: lint
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
24
+ with:
25
+ enable-cache: true
26
+ - name: Install
27
+ run: uv sync --all-groups
28
+ - name: ruff check
29
+ run: uv run ruff check .
30
+ - name: ruff format check
31
+ run: uv run ruff format --check --diff .
32
+ - name: ty
33
+ run: uv run ty check django_data_shape
34
+
35
+ docs:
36
+ name: docs build
37
+ runs-on: ubuntu-latest
38
+ steps:
39
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
41
+ with:
42
+ enable-cache: true
43
+ - name: Install with docs group
44
+ run: uv sync --all-groups
45
+ - name: mkdocs build --strict
46
+ # This package defines no models, so mkdocstrings can import it without
47
+ # django.setup(). The settings module is pointed at anyway because the
48
+ # reference pages document code that reads Django's model _meta API, and
49
+ # a future page that imports the test models would otherwise fail here
50
+ # rather than in the docs build a contributor runs locally.
51
+ run: DJANGO_SETTINGS_MODULE=tests.conftest_settings uv run mkdocs build --strict
52
+
53
+
54
+ floor:
55
+ name: lowest declared versions
56
+ runs-on: ubuntu-latest
57
+ services:
58
+ postgres:
59
+ image: postgres:17
60
+ env:
61
+ POSTGRES_USER: postgres
62
+ POSTGRES_PASSWORD: postgres
63
+ POSTGRES_DB: django_data_shape
64
+ ports:
65
+ - 5432:5432
66
+ options: >-
67
+ --health-cmd pg_isready
68
+ --health-interval 10s
69
+ --health-timeout 5s
70
+ --health-retries 5
71
+ # The floor job installs --all-extras, so the psycopg floor is one of the
72
+ # claims it is checking. Resolving it without ever opening a connection
73
+ # would assert that the version installs, not that it works.
74
+ env:
75
+ PGHOST: localhost
76
+ PGPORT: "5432"
77
+ PGUSER: postgres
78
+ PGPASSWORD: postgres
79
+ PGDATABASE: django_data_shape
80
+ steps:
81
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
82
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
83
+ with:
84
+ enable-cache: true
85
+ # A floor is a claim about the *oldest* version that works, and a lockfile
86
+ # can only ever check the newest. Development and CI both resolve the
87
+ # newest in-range dependency, so every gate passes while a consumer whose
88
+ # own constraints pull an older one gets a package that does not import.
89
+ #
90
+ # `lowest-direct` pins every *declared* dependency to the bottom of its
91
+ # window while letting transitives resolve normally, so this asserts the
92
+ # floors mean what they say. A per-PR gate rather than a scheduled job,
93
+ # because a floor stops being true the moment someone imports a newer
94
+ # upstream symbol -- a code change, not the passage of time.
95
+ #
96
+ # Pinned to the oldest supported Python rather than whatever interpreter
97
+ # the runner exposes. The floor claim is about the oldest configuration
98
+ # we support, so that is the one to install, and leaving the choice to
99
+ # the image makes the answer drift when the image does.
100
+ - name: Install Python 3.10
101
+ run: uv python install 3.10
102
+ # `--refresh` on both resolutions below. This job's whole claim is "what a
103
+ # consumer resolving from scratch would get", and the runner's uv cache is
104
+ # shared across runs: without it the answer comes from whatever package
105
+ # list that cache happens to hold, which can be hours behind the index.
106
+ - name: Resolve every declared dependency at its floor
107
+ run: uv lock --resolution lowest-direct --refresh
108
+ # `--frozen` here and `--no-sync` below, not a bare `uv sync` / `uv run`.
109
+ # uv records the resolution mode in the lockfile and silently discards one
110
+ # resolved in a different mode, so a plain `uv sync` re-resolves at
111
+ # `highest` and undoes the step above -- the job would then measure the
112
+ # newest versions twice while reporting that it tested the oldest.
113
+ - name: Install
114
+ run: uv sync --frozen --all-groups --all-extras --python 3.10
115
+ - name: Name the versions that produced
116
+ run: uv pip list
117
+ - name: Test
118
+ run: uv run --no-sync pytest
119
+
120
+ # The step above installs every extra, and an extra can hold a shared
121
+ # dependency *above* the floor being claimed -- masking the lie rather
122
+ # than exposing it. So the floor claim is also checked on the install
123
+ # shape with the fewest constraints: the package alone, no extras, no dev
124
+ # group. That shape is what a consumer who wants only the default codec
125
+ # actually gets.
126
+ - name: Import the package installed alone, at its floors
127
+ run: |
128
+ uv venv --python 3.10 .venv-floor
129
+ uv pip install --python .venv-floor/bin/python --resolution lowest-direct --refresh -e .
130
+ uv pip list --python .venv-floor/bin/python
131
+ .venv-floor/bin/python - <<'PY'
132
+ import django
133
+ from django.conf import settings
134
+
135
+ # No models here, so the import itself needs no django.setup(). The
136
+ # settings are configured anyway so this asserts the shape a real
137
+ # consumer has: installed alongside Django, at the declared floors.
138
+ settings.configure(
139
+ SECRET_KEY="x",
140
+ INSTALLED_APPS=[
141
+ "django.contrib.contenttypes",
142
+ "django.contrib.auth",
143
+ "django_data_shape",
144
+ ],
145
+ DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
146
+ )
147
+ django.setup()
148
+
149
+ import django_data_shape
150
+
151
+ print("BASE IMPORT OK", django_data_shape.__version__, django.get_version())
152
+ PY
153
+
154
+ test:
155
+ # The portable half: the shape vocabulary, the generator and the invariant
156
+ # arithmetic, across the whole support matrix. Everything this package is
157
+ # actually *for* -- COPY loading, ANALYZE, planner statistics, template
158
+ # databases -- is unreachable on SQLite, so this job cannot be the coverage
159
+ # gate. It asserts that the parts which claim to be backend-neutral are.
160
+ name: ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
161
+ runs-on: ubuntu-latest
162
+ env:
163
+ DATA_SHAPE_TEST_DATABASE: sqlite
164
+ strategy:
165
+ fail-fast: false
166
+ matrix:
167
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
168
+ django-version: ["4.2", "5.0", "5.1", "5.2", "6.0", "6.1"]
169
+ exclude:
170
+ # Django 4.2 supports Python 3.8–3.12
171
+ - python-version: "3.13"
172
+ django-version: "4.2"
173
+ - python-version: "3.14"
174
+ django-version: "4.2"
175
+ # Django 5.0 supports Python 3.10–3.12
176
+ - python-version: "3.13"
177
+ django-version: "5.0"
178
+ - python-version: "3.14"
179
+ django-version: "5.0"
180
+ # Django 5.1 supports Python 3.10–3.13
181
+ - python-version: "3.14"
182
+ django-version: "5.1"
183
+ # Django 6.0 supports Python 3.12+
184
+ - python-version: "3.10"
185
+ django-version: "6.0"
186
+ - python-version: "3.11"
187
+ django-version: "6.0"
188
+ # Django 6.1 requires Python 3.12+
189
+ - python-version: "3.10"
190
+ django-version: "6.1"
191
+ - python-version: "3.11"
192
+ django-version: "6.1"
193
+
194
+ steps:
195
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
196
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
197
+ with:
198
+ enable-cache: true
199
+ - name: Install Python ${{ matrix.python-version }}
200
+ run: uv python install ${{ matrix.python-version }}
201
+ - name: Sync dev deps
202
+ run: uv sync --all-groups --python ${{ matrix.python-version }}
203
+ - name: Pin Django ${{ matrix.django-version }}
204
+ run: uv pip install --reinstall-package django "django~=${{ matrix.django-version }}.0"
205
+ # No coverage gate here: the Postgres job below owns it. Gating both
206
+ # would mean neither could reach 100%, because each backend makes the
207
+ # other's branches unreachable.
208
+ - name: pytest
209
+ run: uv run --no-sync pytest --cov-fail-under=0
210
+
211
+ test-postgres:
212
+ # The canonical run, and the coverage gate. This inverts the arrangement in
213
+ # django-domain-events, and for a stated reason: there the Postgres-only
214
+ # paths were the exception, so the portable matrix could gate. Here the
215
+ # Postgres paths are the product -- COPY loading, ANALYZE, statistics
216
+ # targets, template-database reuse -- and a suite gated on the backend that
217
+ # cannot run them would be green while proving nothing.
218
+ #
219
+ # For that to be reachable at 100%, the backend-refusal paths are written to
220
+ # be testable without a SQLite *database*: they branch on the connection's
221
+ # vendor, so they are exercised by passing a vendor rather than by running
222
+ # the suite twice. A degradation path that can only be covered on the
223
+ # backend it refuses is a degradation path that gates nothing.
224
+ name: postgres / 3.13 / Django ${{ matrix.django-version }}
225
+ runs-on: ubuntu-latest
226
+ strategy:
227
+ fail-fast: false
228
+ matrix:
229
+ # 5.2 is the LTS and the oldest Django with db_default, which is the one
230
+ # behaviour no other Postgres cell covers.
231
+ django-version: ["5.2", "6.1"]
232
+ services:
233
+ postgres:
234
+ image: postgres:17
235
+ env:
236
+ POSTGRES_USER: postgres
237
+ POSTGRES_PASSWORD: postgres
238
+ POSTGRES_DB: django_data_shape
239
+ ports:
240
+ - 5432:5432
241
+ options: >-
242
+ --health-cmd pg_isready
243
+ --health-interval 10s
244
+ --health-timeout 5s
245
+ --health-retries 5
246
+ env:
247
+ PGHOST: localhost
248
+ PGPORT: "5432"
249
+ PGUSER: postgres
250
+ PGPASSWORD: postgres
251
+ PGDATABASE: django_data_shape
252
+ steps:
253
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
254
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
255
+ with:
256
+ enable-cache: true
257
+ - name: Install Python 3.13
258
+ run: uv python install 3.13
259
+ - name: Sync dev deps
260
+ run: uv sync --all-groups --all-extras --python 3.13
261
+ - name: Pin Django ${{ matrix.django-version }}
262
+ run: uv pip install --reinstall-package django "django~=${{ matrix.django-version }}.0"
263
+ - name: pytest against Postgres
264
+ run: |
265
+ uv run --no-sync pytest \
266
+ --cov-report=xml:coverage.xml \
267
+ --cov-report=html:htmlcov
268
+ - name: Upload coverage artifact
269
+ if: always()
270
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
271
+ with:
272
+ name: coverage-postgres-${{ matrix.django-version }}
273
+ path: |
274
+ coverage.xml
275
+ htmlcov/
276
+ if-no-files-found: ignore
277
+
278
+ coverage-badge:
279
+ # Publishes a shields.io-compatible coverage.json to gh-pages so the README
280
+ # badge reflects reality. Runs only on push to main, after the matrix has
281
+ # produced an artifact we can read from.
282
+ name: coverage badge
283
+ needs: test-postgres
284
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
285
+ runs-on: ubuntu-latest
286
+ permissions:
287
+ contents: write
288
+ steps:
289
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
290
+ with:
291
+ # Need the parent commit so the gate step below can diff
292
+ # version.py against ${{ github.event.before }}. The default
293
+ # shallow checkout (depth 1) would make `git show "$before:…"`
294
+ # fail with exit 128 and turn the whole job red.
295
+ fetch-depth: 2
296
+ # release.yml runs in parallel on the same push and owns gh-pages
297
+ # publishing on release commits (it bundles coverage.json into the
298
+ # mkdocs gh-deploy). If we also push here we get a second built-in
299
+ # "pages build and deployment" run that the first one cancels. The
300
+ # version-diff against ${{ github.event.before }} is the reliable
301
+ # signal — release.yml's tag isn't pushed until after PyPI publish,
302
+ # so a tag-existence check would race.
303
+ - name: Skip when release.yml owns this push
304
+ id: gate
305
+ run: |
306
+ set -euo pipefail
307
+ before="${{ github.event.before }}"
308
+ if [[ -z "$before" || "$before" =~ ^0+$ ]]; then
309
+ # First push to main on a brand-new repo — no prior state to diff.
310
+ echo "skip=false" >> "$GITHUB_OUTPUT"
311
+ exit 0
312
+ fi
313
+ # `before` may not be in the local object DB if the push
314
+ # contained more than one commit (fetch-depth: 2 only guarantees
315
+ # HEAD + 1 parent). Try to fetch just that commit; if it's not
316
+ # reachable, fall back to "don't skip" — better to double-push
317
+ # to gh-pages once than to turn the pipeline red.
318
+ if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
319
+ git fetch --depth=1 origin "$before" 2>/dev/null || true
320
+ fi
321
+ old=""
322
+ if git cat-file -e "$before:django_data_shape/version.py" 2>/dev/null; then
323
+ old="$(git show "$before:django_data_shape/version.py" \
324
+ | awk -F'"' '/^__version__/ { print $2; exit }')"
325
+ fi
326
+ new="$(awk -F'"' '/^__version__/ { print $2; exit }' \
327
+ django_data_shape/version.py)"
328
+ if [[ -n "$old" && "$old" != "$new" ]]; then
329
+ echo "Version bumped $old → $new; release.yml will deploy gh-pages."
330
+ echo "skip=true" >> "$GITHUB_OUTPUT"
331
+ else
332
+ echo "skip=false" >> "$GITHUB_OUTPUT"
333
+ fi
334
+ - name: Download canonical coverage artifact
335
+ if: steps.gate.outputs.skip != 'true'
336
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
337
+ with:
338
+ # The Postgres job is the only run that reaches every line, so it is
339
+ # the only one whose number means anything. Pinned to one cell of it:
340
+ # the number is the same across Django versions, and the badge needs
341
+ # one.
342
+ name: coverage-postgres-6.1
343
+ path: coverage-artifact/
344
+ - name: Render coverage.json
345
+ if: steps.gate.outputs.skip != 'true'
346
+ run: |
347
+ python - <<'PY'
348
+ import json
349
+ import xml.etree.ElementTree as ET
350
+
351
+ tree = ET.parse("coverage-artifact/coverage.xml")
352
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
353
+ if pct >= 95:
354
+ color = "brightgreen"
355
+ elif pct >= 85:
356
+ color = "green"
357
+ elif pct >= 70:
358
+ color = "yellow"
359
+ else:
360
+ color = "red"
361
+ payload = {
362
+ "schemaVersion": 1,
363
+ "label": "coverage",
364
+ "message": f"{pct}%",
365
+ "color": color,
366
+ }
367
+ with open("coverage.json", "w") as fh:
368
+ json.dump(payload, fh)
369
+ fh.write("\n")
370
+ print(json.dumps(payload))
371
+ PY
372
+ - name: Publish coverage.json to gh-pages
373
+ if: steps.gate.outputs.skip != 'true'
374
+ env:
375
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
376
+ run: |
377
+ set -euo pipefail
378
+ git config user.name 'github-actions[bot]'
379
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
380
+
381
+ tmp="$(mktemp -d)"
382
+ cp coverage.json "$tmp/coverage.json"
383
+
384
+ git fetch origin gh-pages --depth=1 2>/dev/null || true
385
+ if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
386
+ git worktree add gh-pages origin/gh-pages
387
+ cp "$tmp/coverage.json" gh-pages/coverage.json
388
+ cd gh-pages
389
+ if [[ -n "$(git status --porcelain coverage.json)" ]]; then
390
+ git add coverage.json
391
+ git commit -m "coverage badge: update from ${GITHUB_SHA::7}"
392
+ git push origin HEAD:gh-pages
393
+ else
394
+ echo "coverage.json unchanged, skipping push"
395
+ fi
396
+ else
397
+ # First run before docs deploy. Lay down an orphan gh-pages with
398
+ # just the badge file; the docs deploy will overwrite the rest.
399
+ git worktree add --detach gh-pages
400
+ cd gh-pages
401
+ git checkout --orphan gh-pages
402
+ git rm -rf . >/dev/null 2>&1 || true
403
+ cp "$tmp/coverage.json" coverage.json
404
+ git add coverage.json
405
+ git commit -m "coverage badge: initial publish from ${GITHUB_SHA::7}"
406
+ git push origin gh-pages
407
+ fi
408
+
409
+ secrets:
410
+ name: secret scan
411
+ runs-on: ubuntu-latest
412
+ steps:
413
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
414
+ with:
415
+ fetch-depth: 0
416
+ - name: Install gitleaks
417
+ run: |
418
+ curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
419
+ sudo mv gitleaks /usr/local/bin/
420
+ - name: Scan repo + history
421
+ run: gitleaks git --redact --no-banner .
422
+
423
+ tests-passed:
424
+ name: tests
425
+ if: always()
426
+ needs: [lint, docs, floor, test, test-postgres]
427
+ runs-on: ubuntu-latest
428
+ steps:
429
+ - name: Verify required jobs succeeded
430
+ run: |
431
+ if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
432
+ echo "a required job failed"; exit 1
433
+ fi