django-spec-tasks 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 (112) hide show
  1. django_spec_tasks-0.1.0/.github/CODEOWNERS +1 -0
  2. django_spec_tasks-0.1.0/.github/SECURITY.md +9 -0
  3. django_spec_tasks-0.1.0/.github/dependabot.yml +26 -0
  4. django_spec_tasks-0.1.0/.github/workflows/release.yml +112 -0
  5. django_spec_tasks-0.1.0/.github/workflows/tests.yml +445 -0
  6. django_spec_tasks-0.1.0/.github/workflows/upstream-drift.yml +149 -0
  7. django_spec_tasks-0.1.0/.gitignore +639 -0
  8. django_spec_tasks-0.1.0/.pre-commit-config.yaml +90 -0
  9. django_spec_tasks-0.1.0/CHANGELOG.md +27 -0
  10. django_spec_tasks-0.1.0/CLAUDE.md +174 -0
  11. django_spec_tasks-0.1.0/LICENSE +21 -0
  12. django_spec_tasks-0.1.0/Makefile +90 -0
  13. django_spec_tasks-0.1.0/PKG-INFO +73 -0
  14. django_spec_tasks-0.1.0/README.md +33 -0
  15. django_spec_tasks-0.1.0/django_spec_tasks/__init__.py +60 -0
  16. django_spec_tasks-0.1.0/django_spec_tasks/__init__.pyi +33 -0
  17. django_spec_tasks-0.1.0/django_spec_tasks/admin.py +65 -0
  18. django_spec_tasks-0.1.0/django_spec_tasks/apps.py +35 -0
  19. django_spec_tasks-0.1.0/django_spec_tasks/backends/__init__.py +1 -0
  20. django_spec_tasks-0.1.0/django_spec_tasks/backends/celery_backend.py +41 -0
  21. django_spec_tasks-0.1.0/django_spec_tasks/backends/django_tasks_backend.py +45 -0
  22. django_spec_tasks-0.1.0/django_spec_tasks/binding/__init__.py +1 -0
  23. django_spec_tasks-0.1.0/django_spec_tasks/binding/task_binding.py +93 -0
  24. django_spec_tasks-0.1.0/django_spec_tasks/caller/__init__.py +1 -0
  25. django_spec_tasks-0.1.0/django_spec_tasks/caller/enqueue.py +135 -0
  26. django_spec_tasks-0.1.0/django_spec_tasks/checks.py +39 -0
  27. django_spec_tasks-0.1.0/django_spec_tasks/management/__init__.py +1 -0
  28. django_spec_tasks-0.1.0/django_spec_tasks/management/commands/__init__.py +1 -0
  29. django_spec_tasks-0.1.0/django_spec_tasks/management/commands/prune_spec_runs.py +26 -0
  30. django_spec_tasks-0.1.0/django_spec_tasks/management/commands/sweep_spec_runs.py +30 -0
  31. django_spec_tasks-0.1.0/django_spec_tasks/migrations/0001_initial.py +122 -0
  32. django_spec_tasks-0.1.0/django_spec_tasks/migrations/__init__.py +0 -0
  33. django_spec_tasks-0.1.0/django_spec_tasks/models/__init__.py +3 -0
  34. django_spec_tasks-0.1.0/django_spec_tasks/models/spec_run.py +64 -0
  35. django_spec_tasks-0.1.0/django_spec_tasks/models/storable_json.py +33 -0
  36. django_spec_tasks-0.1.0/django_spec_tasks/py.typed +0 -0
  37. django_spec_tasks-0.1.0/django_spec_tasks/read/__init__.py +1 -0
  38. django_spec_tasks-0.1.0/django_spec_tasks/read/read_run.py +54 -0
  39. django_spec_tasks-0.1.0/django_spec_tasks/read/state_of.py +16 -0
  40. django_spec_tasks-0.1.0/django_spec_tasks/read/utils.py +23 -0
  41. django_spec_tasks-0.1.0/django_spec_tasks/sweep/__init__.py +1 -0
  42. django_spec_tasks-0.1.0/django_spec_tasks/sweep/prune_runs.py +31 -0
  43. django_spec_tasks-0.1.0/django_spec_tasks/sweep/sweep_runs.py +189 -0
  44. django_spec_tasks-0.1.0/django_spec_tasks/types/__init__.py +1 -0
  45. django_spec_tasks-0.1.0/django_spec_tasks/types/run_state.py +28 -0
  46. django_spec_tasks-0.1.0/django_spec_tasks/types/run_status.py +28 -0
  47. django_spec_tasks-0.1.0/django_spec_tasks/types/sweep_report.py +25 -0
  48. django_spec_tasks-0.1.0/django_spec_tasks/types/task_backend.py +17 -0
  49. django_spec_tasks-0.1.0/django_spec_tasks/types/terminal_states.py +6 -0
  50. django_spec_tasks-0.1.0/django_spec_tasks/version.py +7 -0
  51. django_spec_tasks-0.1.0/django_spec_tasks/worker/__init__.py +1 -0
  52. django_spec_tasks-0.1.0/django_spec_tasks/worker/row_progress.py +67 -0
  53. django_spec_tasks-0.1.0/django_spec_tasks/worker/run_spec.py +298 -0
  54. django_spec_tasks-0.1.0/django_spec_tasks/worker/run_state_for.py +42 -0
  55. django_spec_tasks-0.1.0/docs/backends.md +105 -0
  56. django_spec_tasks-0.1.0/docs/domain-events.md +219 -0
  57. django_spec_tasks-0.1.0/docs/index.md +74 -0
  58. django_spec_tasks-0.1.0/docs/operating.md +136 -0
  59. django_spec_tasks-0.1.0/docs/reference.md +66 -0
  60. django_spec_tasks-0.1.0/docs/worker.md +139 -0
  61. django_spec_tasks-0.1.0/mkdocs.yml +85 -0
  62. django_spec_tasks-0.1.0/pyproject.toml +179 -0
  63. django_spec_tasks-0.1.0/scripts/check-changelog +162 -0
  64. django_spec_tasks-0.1.0/scripts/release-publish.sh +246 -0
  65. django_spec_tasks-0.1.0/tests/__init__.py +0 -0
  66. django_spec_tasks-0.1.0/tests/backends/__init__.py +0 -0
  67. django_spec_tasks-0.1.0/tests/backends/conftest.py +56 -0
  68. django_spec_tasks-0.1.0/tests/backends/test_celery_backend.py +77 -0
  69. django_spec_tasks-0.1.0/tests/backends/test_celery_registration.py +78 -0
  70. django_spec_tasks-0.1.0/tests/backends/test_django_tasks_backend.py +153 -0
  71. django_spec_tasks-0.1.0/tests/backends/test_without_a_queue.py +38 -0
  72. django_spec_tasks-0.1.0/tests/backends/utils.py +20 -0
  73. django_spec_tasks-0.1.0/tests/binding/__init__.py +0 -0
  74. django_spec_tasks-0.1.0/tests/binding/test_task_binding.py +87 -0
  75. django_spec_tasks-0.1.0/tests/caller/__init__.py +0 -0
  76. django_spec_tasks-0.1.0/tests/caller/conftest.py +157 -0
  77. django_spec_tasks-0.1.0/tests/caller/test_enqueue.py +398 -0
  78. django_spec_tasks-0.1.0/tests/conftest.py +23 -0
  79. django_spec_tasks-0.1.0/tests/conftest_settings.py +74 -0
  80. django_spec_tasks-0.1.0/tests/models/__init__.py +0 -0
  81. django_spec_tasks-0.1.0/tests/models/test_spec_run.py +115 -0
  82. django_spec_tasks-0.1.0/tests/read/__init__.py +0 -0
  83. django_spec_tasks-0.1.0/tests/read/conftest.py +59 -0
  84. django_spec_tasks-0.1.0/tests/read/test_read_run.py +242 -0
  85. django_spec_tasks-0.1.0/tests/read/test_spec_run_admin.py +191 -0
  86. django_spec_tasks-0.1.0/tests/sweep/__init__.py +0 -0
  87. django_spec_tasks-0.1.0/tests/sweep/conftest.py +92 -0
  88. django_spec_tasks-0.1.0/tests/sweep/test_commands.py +82 -0
  89. django_spec_tasks-0.1.0/tests/sweep/test_prune_runs.py +68 -0
  90. django_spec_tasks-0.1.0/tests/sweep/test_sweep_runs.py +835 -0
  91. django_spec_tasks-0.1.0/tests/test_apps.py +64 -0
  92. django_spec_tasks-0.1.0/tests/test_checks.py +69 -0
  93. django_spec_tasks-0.1.0/tests/test_event_seams.py +262 -0
  94. django_spec_tasks-0.1.0/tests/test_import_ratchet.py +111 -0
  95. django_spec_tasks-0.1.0/tests/test_init.py +108 -0
  96. django_spec_tasks-0.1.0/tests/test_migrations.py +10 -0
  97. django_spec_tasks-0.1.0/tests/test_package_layout.py +33 -0
  98. django_spec_tasks-0.1.0/tests/testapp/__init__.py +0 -0
  99. django_spec_tasks-0.1.0/tests/testapp/models.py +25 -0
  100. django_spec_tasks-0.1.0/tests/testapp/spec_tasks.py +14 -0
  101. django_spec_tasks-0.1.0/tests/types/__init__.py +0 -0
  102. django_spec_tasks-0.1.0/tests/types/test_run_state.py +31 -0
  103. django_spec_tasks-0.1.0/tests/urls.py +6 -0
  104. django_spec_tasks-0.1.0/tests/worker/__init__.py +0 -0
  105. django_spec_tasks-0.1.0/tests/worker/conftest.py +67 -0
  106. django_spec_tasks-0.1.0/tests/worker/test_claim.py +218 -0
  107. django_spec_tasks-0.1.0/tests/worker/test_refusals.py +398 -0
  108. django_spec_tasks-0.1.0/tests/worker/test_result.py +402 -0
  109. django_spec_tasks-0.1.0/tests/worker/test_row_progress.py +272 -0
  110. django_spec_tasks-0.1.0/tests/worker/test_run_state_for.py +90 -0
  111. django_spec_tasks-0.1.0/tests/worker/test_transaction.py +486 -0
  112. django_spec_tasks-0.1.0/uv.lock +2173 -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,112 @@
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-spec-tasks
27
+ permissions:
28
+ id-token: write # PyPI OIDC trusted publishing
29
+ contents: write # tag push + gh release create
30
+ steps:
31
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32
+ with:
33
+ fetch-depth: 0
34
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
35
+ with:
36
+ enable-cache: true
37
+ - name: Install
38
+ run: uv sync --all-groups
39
+
40
+ - name: Prepare release (version check, test, build dist)
41
+ id: prepare
42
+ run: make release-publish-prepare
43
+
44
+ - name: Publish to PyPI
45
+ if: steps.prepare.outputs.released == 'true'
46
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
47
+ with:
48
+ # Idempotent on purpose. What gates a release is the git tag, not the
49
+ # index (see `prepare`), so re-running this job after a failure in a
50
+ # later step arrives here having already uploaded these files. Without
51
+ # this the upload is the step that makes that re-run impossible, and
52
+ # every recovery becomes manual — which is how one release lost its tag,
53
+ # its GitHub Release and its docs deploy to a single transient push
54
+ # failure, with the package already published.
55
+ # PyPI never lets a file be replaced, so skipping one cannot overwrite
56
+ # a published artefact.
57
+ skip-existing: true
58
+
59
+ - name: Tag + GitHub Release
60
+ if: steps.prepare.outputs.released == 'true'
61
+ env:
62
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
+ run: make release-publish-finalize
64
+
65
+ # On release commits we own the single gh-pages push: embed the
66
+ # coverage badge JSON into the docs build so mkdocs gh-deploy carries
67
+ # it alongside the site, and tests.yml's coverage-badge job
68
+ # short-circuits to avoid a second push (and a second built-in
69
+ # "pages build and deployment" run).
70
+ - name: Render coverage badge for the docs site
71
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
72
+ run: |
73
+ set -euo pipefail
74
+ # release-publish-prepare's pytest run wrote .coverage to the
75
+ # workspace root; convert to coverage.xml so the same parser
76
+ # we use in tests.yml can score it.
77
+ uv run coverage xml -o coverage.xml
78
+ python - <<'PY'
79
+ import json
80
+ import xml.etree.ElementTree as ET
81
+
82
+ tree = ET.parse("coverage.xml")
83
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
84
+ if pct >= 95:
85
+ color = "brightgreen"
86
+ elif pct >= 85:
87
+ color = "green"
88
+ elif pct >= 70:
89
+ color = "yellow"
90
+ else:
91
+ color = "red"
92
+ payload = {
93
+ "schemaVersion": 1,
94
+ "label": "coverage",
95
+ "message": f"{pct}%",
96
+ "color": color,
97
+ }
98
+ # docs/ is mkdocs' source dir; non-.md files are copied verbatim
99
+ # into site/, which lands at the gh-pages root after gh-deploy.
100
+ with open("docs/coverage.json", "w") as fh:
101
+ json.dump(payload, fh)
102
+ fh.write("\n")
103
+ print(json.dumps(payload))
104
+ PY
105
+
106
+ - name: Deploy docs + coverage badge to gh-pages
107
+ if: steps.prepare.outputs.released == 'true' && hashFiles('mkdocs.yml') != ''
108
+ run: |
109
+ git config user.name 'github-actions[bot]'
110
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
111
+ DJANGO_SETTINGS_MODULE=tests.conftest_settings \
112
+ uv run mkdocs gh-deploy --force --clean --config-file mkdocs.yml
@@ -0,0 +1,445 @@
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@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
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_spec_tasks
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@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
41
+ with:
42
+ enable-cache: true
43
+ - name: Install with docs group
44
+ run: uv sync --all-groups
45
+ - name: mkdocs build --strict
46
+ # mkdocstrings imports the package, and a Django package can read
47
+ # ``django.conf.settings`` at import time. Pointing at the test settings
48
+ # module keeps the build hermetic rather than dependent on whether any
49
+ # module happens to do so today.
50
+ run: DJANGO_SETTINGS_MODULE=tests.conftest_settings uv run mkdocs build --strict
51
+
52
+ floor:
53
+ name: lowest declared versions
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
58
+ with:
59
+ enable-cache: true
60
+ # A floor is a claim about the *oldest* version that works, and a lockfile
61
+ # can only ever check the newest. Development and CI both resolve the
62
+ # newest in-range dependency, so every gate passes while a consumer whose
63
+ # own constraints pull an older one gets a package that does not import.
64
+ #
65
+ # `lowest-direct` pins every *declared* dependency to the bottom of its
66
+ # window while letting transitives resolve normally, so this asserts the
67
+ # floors mean what they say. A per-PR gate rather than a scheduled job,
68
+ # because a floor stops being true the moment someone imports a newer
69
+ # upstream symbol -- a code change, not the passage of time.
70
+ #
71
+ # Pinned to the oldest supported Python rather than whatever interpreter
72
+ # the runner exposes. The floor claim is about the oldest configuration
73
+ # we support, so that is the one to install, and leaving the choice to
74
+ # the image makes the answer drift when the image does.
75
+ - name: Install Python 3.10
76
+ run: uv python install 3.10
77
+ # `--refresh` on both resolutions below. This job's whole claim is "what a
78
+ # consumer resolving from scratch would get", and the runner's uv cache is
79
+ # shared across runs: without it the answer comes from whatever package
80
+ # list that cache happens to hold, which can be hours behind the index.
81
+ - name: Resolve every declared dependency at its floor
82
+ run: uv lock --resolution lowest-direct --refresh
83
+ # `--frozen` here and `--no-sync` below, not a bare `uv sync` / `uv run`.
84
+ # uv records the resolution mode in the lockfile and silently discards one
85
+ # resolved in a different mode, so a plain `uv sync` re-resolves at
86
+ # `highest` and undoes the step above -- the job would then measure the
87
+ # newest versions twice while reporting that it tested the oldest.
88
+ - name: Install
89
+ run: uv sync --frozen --all-groups --all-extras --python 3.10
90
+ - name: Name the versions that produced
91
+ run: uv pip list
92
+ - name: Test
93
+ run: uv run --no-sync pytest
94
+
95
+ # The step above installs every group and extra, and either can hold a
96
+ # shared dependency *above* the floor being claimed, masking the lie
97
+ # rather than exposing it. So the floor claim is also checked on the
98
+ # install shape with the fewest constraints: the package alone, no extras
99
+ # and no dev group.
100
+ - name: Import the package installed alone, at its floors
101
+ run: |
102
+ uv venv --python 3.10 .venv-floor
103
+ uv pip install --python .venv-floor/bin/python --resolution lowest-direct --refresh -e .
104
+ uv pip list --python .venv-floor/bin/python
105
+ .venv-floor/bin/python - <<'PY'
106
+ import django
107
+ from django.conf import settings
108
+
109
+ # A Django app holding a model, so it is imported the way a project
110
+ # imports it: listed, after django.setup() against configured settings.
111
+ settings.configure(
112
+ SECRET_KEY="x",
113
+ INSTALLED_APPS=[
114
+ "django.contrib.contenttypes",
115
+ "django.contrib.auth",
116
+ "django_spec_tasks",
117
+ ],
118
+ DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
119
+ )
120
+ django.setup()
121
+
122
+ import sys
123
+
124
+ import django_spec_tasks
125
+
126
+ # The kernel it serves exists for dispatch without DRF, and a queue
127
+ # is one of the transports that needs none, so the install that
128
+ # proves the floors also proves neither package reaches for it.
129
+ # Nothing here installs DRF, so an import of it would already fail
130
+ # above; this names the claim.
131
+ assert "rest_framework" not in sys.modules, "the package imported DRF"
132
+
133
+ print("BASE IMPORT OK", django_spec_tasks.__version__, django.get_version())
134
+ PY
135
+
136
+ extras:
137
+ # Every install shape a project can choose, each alone: no queue, Celery,
138
+ # the Tasks backport. The other jobs install both, so a module that imports
139
+ # one queue where only the other is present passes all of them.
140
+ name: extras (${{ matrix.extra || 'none' }})
141
+ runs-on: ubuntu-latest
142
+ strategy:
143
+ fail-fast: false
144
+ matrix:
145
+ extra: ["", "celery", "django-tasks"]
146
+ steps:
147
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
148
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
149
+ with:
150
+ enable-cache: true
151
+ - name: Install the package with this extra alone
152
+ env:
153
+ EXTRA: ${{ matrix.extra }}
154
+ run: |
155
+ uv venv --python 3.13 .venv-extra
156
+ uv pip install --python .venv-extra/bin/python -e ".${EXTRA:+[$EXTRA]}"
157
+ uv pip list --python .venv-extra/bin/python
158
+ - name: Import the package the way a project does
159
+ env:
160
+ EXTRA: ${{ matrix.extra }}
161
+ run: |
162
+ .venv-extra/bin/python - <<'PY'
163
+ import importlib.util
164
+ import os
165
+ import sys
166
+
167
+ import django
168
+ from django.conf import settings
169
+
170
+ settings.configure(
171
+ SECRET_KEY="x",
172
+ INSTALLED_APPS=[
173
+ "django.contrib.contenttypes",
174
+ "django.contrib.auth",
175
+ "django_spec_tasks",
176
+ ],
177
+ DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
178
+ )
179
+ django.setup()
180
+
181
+ import django_spec_tasks
182
+
183
+ extra = os.environ["EXTRA"]
184
+ # The install really is the shape it claims, or this job proves nothing.
185
+ assert (importlib.util.find_spec("celery") is not None) == (extra == "celery")
186
+ assert (importlib.util.find_spec("django_tasks") is not None) == (extra == "django-tasks")
187
+ assert "rest_framework" not in sys.modules, "the package imported DRF"
188
+
189
+ # The caller's half imports in every shape, with whichever queue is absent absent.
190
+ from django_spec_tasks import enqueue
191
+
192
+ assert callable(enqueue)
193
+
194
+ if extra == "celery":
195
+ # A worker finds a message's task by name in its own registry, so startup alone
196
+ # has to have registered it.
197
+ from celery import current_app
198
+
199
+ assert "django_spec_tasks.run_spec" in current_app.tasks, "task not registered"
200
+ if extra == "django-tasks":
201
+ from django_spec_tasks.backends.django_tasks_backend import DjangoTasksBackend
202
+
203
+ assert DjangoTasksBackend().alias == "default"
204
+
205
+ print("IMPORT OK", repr(extra), django_spec_tasks.__version__, django.get_version())
206
+ PY
207
+
208
+ test:
209
+ name: ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
210
+ runs-on: ubuntu-latest
211
+ strategy:
212
+ fail-fast: false
213
+ matrix:
214
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
215
+ django-version: ["4.2", "5.0", "5.1", "5.2", "6.0", "6.1"]
216
+ exclude:
217
+ # Django 4.2 supports Python 3.8–3.12
218
+ - python-version: "3.13"
219
+ django-version: "4.2"
220
+ - python-version: "3.14"
221
+ django-version: "4.2"
222
+ # Django 5.0 supports Python 3.10–3.12
223
+ - python-version: "3.13"
224
+ django-version: "5.0"
225
+ - python-version: "3.14"
226
+ django-version: "5.0"
227
+ # Django 5.1 supports Python 3.10–3.13
228
+ - python-version: "3.14"
229
+ django-version: "5.1"
230
+ # Django 6.0 supports Python 3.12+
231
+ - python-version: "3.10"
232
+ django-version: "6.0"
233
+ - python-version: "3.11"
234
+ django-version: "6.0"
235
+ # Django 6.1 requires Python 3.12+
236
+ - python-version: "3.10"
237
+ django-version: "6.1"
238
+ - python-version: "3.11"
239
+ django-version: "6.1"
240
+
241
+ steps:
242
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
243
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
244
+ with:
245
+ enable-cache: true
246
+ - name: Install Python ${{ matrix.python-version }}
247
+ run: uv python install ${{ matrix.python-version }}
248
+ - name: Sync dev deps
249
+ run: uv sync --all-groups --python ${{ matrix.python-version }}
250
+ - name: Pin Django ${{ matrix.django-version }}
251
+ run: uv pip install --reinstall-package django "django~=${{ matrix.django-version }}.0"
252
+ - name: pytest
253
+ run: |
254
+ uv run --no-sync pytest \
255
+ --cov-report=xml:coverage.xml \
256
+ --cov-report=html:htmlcov
257
+ - name: Upload coverage artifact
258
+ if: always()
259
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
260
+ with:
261
+ name: coverage-py${{ matrix.python-version }}-django${{ matrix.django-version }}
262
+ path: |
263
+ coverage.xml
264
+ htmlcov/
265
+ if-no-files-found: ignore
266
+
267
+ coverage-badge:
268
+ # Publishes a shields.io-compatible coverage.json to gh-pages so the README
269
+ # badge reflects reality. Runs only on push to main, after the matrix has
270
+ # produced an artifact we can read from.
271
+ name: coverage badge
272
+ needs: test
273
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
274
+ runs-on: ubuntu-latest
275
+ permissions:
276
+ contents: write
277
+ steps:
278
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
279
+ with:
280
+ # Need the parent commit so the gate step below can diff
281
+ # version.py against ${{ github.event.before }}. The default
282
+ # shallow checkout (depth 1) would make `git show "$before:…"`
283
+ # fail with exit 128 and turn the whole job red.
284
+ fetch-depth: 2
285
+ # release.yml runs in parallel on the same push and owns gh-pages
286
+ # publishing on release commits (it bundles coverage.json into the
287
+ # mkdocs gh-deploy). If we also push here we get a second built-in
288
+ # "pages build and deployment" run that the first one cancels. The
289
+ # version-diff against ${{ github.event.before }} is the reliable
290
+ # signal — release.yml's tag isn't pushed until after PyPI publish,
291
+ # so a tag-existence check would race.
292
+ - name: Skip when release.yml owns this push
293
+ id: gate
294
+ run: |
295
+ set -euo pipefail
296
+ before="${{ github.event.before }}"
297
+ if [[ -z "$before" || "$before" =~ ^0+$ ]]; then
298
+ # First push to main on a brand-new repo — no prior state to diff.
299
+ echo "skip=false" >> "$GITHUB_OUTPUT"
300
+ exit 0
301
+ fi
302
+ # `before` may not be in the local object DB if the push
303
+ # contained more than one commit (fetch-depth: 2 only guarantees
304
+ # HEAD + 1 parent). Try to fetch just that commit; if it's not
305
+ # reachable, fall back to "don't skip" — better to double-push
306
+ # to gh-pages once than to turn the pipeline red.
307
+ if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
308
+ git fetch --depth=1 origin "$before" 2>/dev/null || true
309
+ fi
310
+ old=""
311
+ if git cat-file -e "$before:django_spec_tasks/version.py" 2>/dev/null; then
312
+ old="$(git show "$before:django_spec_tasks/version.py" \
313
+ | awk -F'"' '/^__version__/ { print $2; exit }')"
314
+ fi
315
+ new="$(awk -F'"' '/^__version__/ { print $2; exit }' \
316
+ django_spec_tasks/version.py)"
317
+ if [[ -n "$old" && "$old" != "$new" ]]; then
318
+ echo "Version bumped $old → $new; release.yml will deploy gh-pages."
319
+ echo "skip=true" >> "$GITHUB_OUTPUT"
320
+ else
321
+ echo "skip=false" >> "$GITHUB_OUTPUT"
322
+ fi
323
+ - name: Download canonical coverage artifact
324
+ if: steps.gate.outputs.skip != 'true'
325
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
326
+ with:
327
+ # Pin to one cell of the matrix — coverage is the same shape across
328
+ # all cells and we only need one number.
329
+ name: coverage-py3.13-django5.2
330
+ path: coverage-artifact/
331
+ - name: Render coverage.json
332
+ if: steps.gate.outputs.skip != 'true'
333
+ run: |
334
+ python - <<'PY'
335
+ import json
336
+ import xml.etree.ElementTree as ET
337
+
338
+ tree = ET.parse("coverage-artifact/coverage.xml")
339
+ pct = round(float(tree.getroot().attrib["line-rate"]) * 100, 1)
340
+ if pct >= 95:
341
+ color = "brightgreen"
342
+ elif pct >= 85:
343
+ color = "green"
344
+ elif pct >= 70:
345
+ color = "yellow"
346
+ else:
347
+ color = "red"
348
+ payload = {
349
+ "schemaVersion": 1,
350
+ "label": "coverage",
351
+ "message": f"{pct}%",
352
+ "color": color,
353
+ }
354
+ with open("coverage.json", "w") as fh:
355
+ json.dump(payload, fh)
356
+ fh.write("\n")
357
+ print(json.dumps(payload))
358
+ PY
359
+ - name: Publish coverage.json to gh-pages
360
+ if: steps.gate.outputs.skip != 'true'
361
+ env:
362
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
363
+ run: |
364
+ set -euo pipefail
365
+ git config user.name 'github-actions[bot]'
366
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
367
+
368
+ tmp="$(mktemp -d)"
369
+ cp coverage.json "$tmp/coverage.json"
370
+
371
+ git fetch origin gh-pages --depth=1 2>/dev/null || true
372
+ if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
373
+ git worktree add gh-pages origin/gh-pages
374
+ cp "$tmp/coverage.json" gh-pages/coverage.json
375
+ cd gh-pages
376
+ if [[ -n "$(git status --porcelain coverage.json)" ]]; then
377
+ git add coverage.json
378
+ git commit -m "coverage badge: update from ${GITHUB_SHA::7}"
379
+ git push origin HEAD:gh-pages
380
+ else
381
+ echo "coverage.json unchanged, skipping push"
382
+ fi
383
+ else
384
+ # First run before docs deploy. Lay down an orphan gh-pages with
385
+ # just the badge file; the docs deploy will overwrite the rest.
386
+ git worktree add --detach gh-pages
387
+ cd gh-pages
388
+ git checkout --orphan gh-pages
389
+ git rm -rf . >/dev/null 2>&1 || true
390
+ cp "$tmp/coverage.json" coverage.json
391
+ git add coverage.json
392
+ git commit -m "coverage badge: initial publish from ${GITHUB_SHA::7}"
393
+ git push origin gh-pages
394
+ fi
395
+
396
+ secrets:
397
+ name: secret scan
398
+ runs-on: ubuntu-latest
399
+ steps:
400
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
401
+ with:
402
+ fetch-depth: 0
403
+ - name: Install gitleaks
404
+ run: |
405
+ curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
406
+ sudo mv gitleaks /usr/local/bin/
407
+ - name: Scan repo + history
408
+ run: gitleaks git --redact --no-banner .
409
+
410
+ tests-passed:
411
+ name: tests
412
+ # always(), never !cancelled(): a skipped job reports a skipped check, and
413
+ # branch protection counts a skipped required check as passing.
414
+ if: always()
415
+ needs: [lint, docs, floor, extras, test]
416
+ runs-on: ubuntu-latest
417
+ steps:
418
+ # Pass on an exact `success` from every job above, rather than failing on
419
+ # a list of bad results: such a list passes whatever it does not name, and
420
+ # GitHub reports a result it does not document. A job no hosted runner
421
+ # ever picks up ("The job was not acquired by Runner of type hosted even
422
+ # after multiple attempts") shows as cancelled on its own check but
423
+ # reaches `needs` as `abandoned`, and a gate that failed only on `failure`
424
+ # and `cancelled` went green over exactly that while the jobs it guards
425
+ # never ran. A matrix reaches `needs` as one result, and one cell that did
426
+ # not succeed is enough to make it something other than `success`.
427
+ #
428
+ # None of the jobs above has an `if:`, so `skipped` can only mean one of
429
+ # them never ran, and it fails like any other result. A job that gains a
430
+ # legitimate skip gets an exception naming it, never `skipped` accepted
431
+ # from all of them.
432
+ #
433
+ # The results come in through `env` rather than being spliced into the
434
+ # script, and every one is printed, so a red gate says which job and what
435
+ # GitHub reported for it.
436
+ - name: Verify every required job succeeded
437
+ env:
438
+ NEEDS: ${{ toJSON(needs) }}
439
+ run: |
440
+ echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
441
+ not_ok="$(echo "$NEEDS" | jq -r '[to_entries[] | select(.value.result != "success") | "\(.key)=\(.value.result)"] | join(" ")')"
442
+ if [ -n "$not_ok" ]; then
443
+ echo "::error::required jobs did not succeed: $not_ok"
444
+ exit 1
445
+ fi