pydj 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 (79) hide show
  1. pydj-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. pydj-0.1.0/.github/ISSUE_TEMPLATE/config.yml +11 -0
  3. pydj-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +68 -0
  4. pydj-0.1.0/.github/pull_request_template.md +27 -0
  5. pydj-0.1.0/.github/workflows/ci.yml +263 -0
  6. pydj-0.1.0/.github/workflows/release.yml +146 -0
  7. pydj-0.1.0/.gitignore +70 -0
  8. pydj-0.1.0/CHANGELOG.md +94 -0
  9. pydj-0.1.0/CONTRIBUTING.md +119 -0
  10. pydj-0.1.0/LICENSE +384 -0
  11. pydj-0.1.0/PKG-INFO +694 -0
  12. pydj-0.1.0/README.md +656 -0
  13. pydj-0.1.0/README.zh.md +627 -0
  14. pydj-0.1.0/SECURITY.md +60 -0
  15. pydj-0.1.0/examples/01_basic_scan.py +32 -0
  16. pydj-0.1.0/examples/02_structured_output.py +55 -0
  17. pydj-0.1.0/examples/03_fine_grained.py +87 -0
  18. pydj-0.1.0/examples/04_custom_transport.py +204 -0
  19. pydj-0.1.0/examples/05_custom_plugin.py +109 -0
  20. pydj-0.1.0/examples/06_batch_scan.py +95 -0
  21. pydj-0.1.0/examples/README.md +110 -0
  22. pydj-0.1.0/examples/site_fixture.py +178 -0
  23. pydj-0.1.0/pyproject.toml +116 -0
  24. pydj-0.1.0/src/pydj/__init__.py +127 -0
  25. pydj-0.1.0/src/pydj/__main__.py +6 -0
  26. pydj-0.1.0/src/pydj/api.py +333 -0
  27. pydj-0.1.0/src/pydj/cli.py +379 -0
  28. pydj-0.1.0/src/pydj/decode.py +110 -0
  29. pydj-0.1.0/src/pydj/fetcher/__init__.py +33 -0
  30. pydj-0.1.0/src/pydj/fetcher/cache.py +313 -0
  31. pydj-0.1.0/src/pydj/fetcher/fetcher.py +519 -0
  32. pydj-0.1.0/src/pydj/fetcher/transport.py +401 -0
  33. pydj-0.1.0/src/pydj/knowledge.py +91 -0
  34. pydj-0.1.0/src/pydj/operations.py +628 -0
  35. pydj-0.1.0/src/pydj/output.py +123 -0
  36. pydj-0.1.0/src/pydj/pipeline.py +1379 -0
  37. pydj-0.1.0/src/pydj/plugins/__init__.py +117 -0
  38. pydj-0.1.0/src/pydj/plugins/base.py +137 -0
  39. pydj-0.1.0/src/pydj/plugins/dynamic_import.py +45 -0
  40. pydj-0.1.0/src/pydj/plugins/emp.py +101 -0
  41. pydj-0.1.0/src/pydj/plugins/esm_import.py +53 -0
  42. pydj-0.1.0/src/pydj/plugins/helmicro.py +159 -0
  43. pydj-0.1.0/src/pydj/plugins/html_pivot.py +133 -0
  44. pydj-0.1.0/src/pydj/plugins/html_script.py +134 -0
  45. pydj-0.1.0/src/pydj/plugins/microapp.py +203 -0
  46. pydj-0.1.0/src/pydj/plugins/microapp_helpers.py +94 -0
  47. pydj-0.1.0/src/pydj/plugins/modernjs.py +110 -0
  48. pydj-0.1.0/src/pydj/plugins/module_federation.py +125 -0
  49. pydj-0.1.0/src/pydj/plugins/module_federation_manifest.py +249 -0
  50. pydj-0.1.0/src/pydj/plugins/nextjs.py +432 -0
  51. pydj-0.1.0/src/pydj/plugins/nuxt.py +39 -0
  52. pydj-0.1.0/src/pydj/plugins/requirejs.py +94 -0
  53. pydj-0.1.0/src/pydj/plugins/script_create.py +53 -0
  54. pydj-0.1.0/src/pydj/plugins/sourcemap.py +108 -0
  55. pydj-0.1.0/src/pydj/plugins/sveltekit.py +46 -0
  56. pydj-0.1.0/src/pydj/plugins/trunk.py +118 -0
  57. pydj-0.1.0/src/pydj/plugins/umijs.py +107 -0
  58. pydj-0.1.0/src/pydj/plugins/universal_url.py +216 -0
  59. pydj-0.1.0/src/pydj/plugins/urlpattern.py +68 -0
  60. pydj-0.1.0/src/pydj/plugins/vite.py +129 -0
  61. pydj-0.1.0/src/pydj/plugins/webpack.py +715 -0
  62. pydj-0.1.0/src/pydj/sourcemap/__init__.py +41 -0
  63. pydj-0.1.0/src/pydj/sourcemap/paths.py +137 -0
  64. pydj-0.1.0/src/pydj/sourcemap/sourcemap.py +256 -0
  65. pydj-0.1.0/src/pydj/sourcemap/vlq.py +129 -0
  66. pydj-0.1.0/src/pydj/transport_protocol.py +219 -0
  67. pydj-0.1.0/src/pydj/types.py +356 -0
  68. pydj-0.1.0/src/pydj/urls.py +186 -0
  69. pydj-0.1.0/tests/conftest.py +94 -0
  70. pydj-0.1.0/tests/test_cli_api.py +541 -0
  71. pydj-0.1.0/tests/test_core.py +341 -0
  72. pydj-0.1.0/tests/test_differential.py +336 -0
  73. pydj-0.1.0/tests/test_fetcher.py +424 -0
  74. pydj-0.1.0/tests/test_injectable_transport.py +482 -0
  75. pydj-0.1.0/tests/test_operations.py +451 -0
  76. pydj-0.1.0/tests/test_pipeline.py +789 -0
  77. pydj-0.1.0/tests/test_plugins.py +918 -0
  78. pydj-0.1.0/tests/test_sourcemap.py +268 -0
  79. pydj-0.1.0/uv.lock +472 -0
@@ -0,0 +1,107 @@
1
+ name: Bug report
2
+ description: Something is broken — a crash, wrong output, or a site not extracted correctly
3
+ title: "[Bug]: "
4
+ labels: ["bug"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ Thanks for the report. The single most useful thing you can include is
10
+ **the URL**, if a specific site is involved.
11
+
12
+ - type: dropdown
13
+ id: interface
14
+ attributes:
15
+ label: How are you using pydj?
16
+ options:
17
+ - CLI (pydj / dj)
18
+ - Library (from pydj import scan)
19
+ - Both
20
+ validations:
21
+ required: true
22
+
23
+ - type: textarea
24
+ id: what-happened
25
+ attributes:
26
+ label: What happened?
27
+ description: The actual behaviour, including any error output.
28
+ placeholder: |
29
+ Running `pydj https://example.com` reports 0 JS files, but the page
30
+ clearly loads several.
31
+ validations:
32
+ required: true
33
+
34
+ - type: textarea
35
+ id: expected
36
+ attributes:
37
+ label: What did you expect?
38
+ placeholder: It should find the scripts referenced by the page.
39
+ validations:
40
+ required: true
41
+
42
+ - type: textarea
43
+ id: reproduce
44
+ attributes:
45
+ label: Steps to reproduce
46
+ description: The exact command or code, so it can be run verbatim.
47
+ render: shell
48
+ placeholder: |
49
+ pipx install 'pydj[tls]'
50
+ pydj -f json --no-cache https://example.com
51
+ validations:
52
+ required: true
53
+
54
+ - type: input
55
+ id: version
56
+ attributes:
57
+ label: pydj version
58
+ description: Output of `pydj --version`.
59
+ placeholder: pydj 0.1.0
60
+ validations:
61
+ required: true
62
+
63
+ - type: input
64
+ id: python
65
+ attributes:
66
+ label: Python version and OS
67
+ description: Output of `python -V`, plus your platform.
68
+ placeholder: Python 3.12.4 on macOS 15.1 (arm64)
69
+ validations:
70
+ required: true
71
+
72
+ - type: checkboxes
73
+ id: tls
74
+ attributes:
75
+ label: TLS impersonation
76
+ description: |
77
+ Whether browser TLS fingerprinting is active changes the outcome on
78
+ sites behind Cloudflare and similar.
79
+ options:
80
+ - label: I installed the `tls` extra (so `curl-cffi` is present)
81
+ - label: "`pydj --debug` reports TLS fingerprint impersonation is disabled"
82
+
83
+ - type: dropdown
84
+ id: go-comparison
85
+ attributes:
86
+ label: Does the Go implementation behave the same way?
87
+ description: |
88
+ pydj is a port, so this instantly distinguishes "the port is wrong" from
89
+ "the original does this too". Skip if you have not tried it.
90
+ options:
91
+ - "Not tried"
92
+ - "Go finds it correctly — pydj is missing it"
93
+ - "Go behaves the same way"
94
+ - "Go misses it too"
95
+ validations:
96
+ required: true
97
+
98
+ - type: textarea
99
+ id: debug
100
+ attributes:
101
+ label: Debug output
102
+ description: |
103
+ If you can, attach the tail of `pydj --debug -f json <url>` (it is
104
+ verbose; the last ~100 lines usually suffice).
105
+ render: shell
106
+ validations:
107
+ required: false
@@ -0,0 +1,11 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Security vulnerability
4
+ url: https://github.com/ejfkdev/pydj/security/advisories/new
5
+ about: Report privately, not as a public issue. See SECURITY.md.
6
+ - name: Question or usage help
7
+ url: https://github.com/ejfkdev/pydj/discussions
8
+ about: For "how do I…" questions. The README and examples/ answer most of them.
9
+ - name: The original Go implementation
10
+ url: https://github.com/ejfkdev/dj/issues
11
+ about: If the Go tool behaves the same way, the issue may belong upstream.
@@ -0,0 +1,68 @@
1
+ name: Feature request
2
+ description: Suggest a bundler, framework or loading pattern pydj should recognise
3
+ title: "[Feature]: "
4
+ labels: ["enhancement"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ Most feature requests here are about **discovery coverage** — a bundler
10
+ whose chunk URL scheme pydj does not recognise. Those are the easiest to
11
+ action, and a URL plus the relevant code snippet usually makes it a
12
+ small, well-defined change.
13
+
14
+ - type: dropdown
15
+ id: kind
16
+ attributes:
17
+ label: What kind of request is this?
18
+ options:
19
+ - A site or bundler whose JS is not extracted (coverage)
20
+ - A new library API or option
21
+ - A new CLI option
22
+ - Documentation
23
+ - Other
24
+ validations:
25
+ required: true
26
+
27
+ - type: textarea
28
+ id: problem
29
+ attributes:
30
+ label: What problem are you hitting?
31
+ description: Describe the situation, not the solution.
32
+ placeholder: |
33
+ A site built with X loads its chunks via `X.load("name")` and pydj finds
34
+ only the entry bundle.
35
+ validations:
36
+ required: true
37
+
38
+ - type: textarea
39
+ id: evidence
40
+ attributes:
41
+ label: Evidence from the site
42
+ description: |
43
+ If this is a coverage request, the URL and the code that builds the
44
+ chunk URLs. Grep the bundle for the chunk path pattern — the surrounding
45
+ function is what a plugin has to match.
46
+ render: shell
47
+ placeholder: |
48
+ # from the site's bundle
49
+ l.u = function (e) { return "static/js/" + e + "." + {10: "abc123"}[e] + ".js" }
50
+ validations:
51
+ required: false
52
+
53
+ - type: textarea
54
+ id: proposal
55
+ attributes:
56
+ label: What would you like to see?
57
+ validations:
58
+ required: true
59
+
60
+ - type: checkboxes
61
+ id: contribute
62
+ attributes:
63
+ label: Would you like to work on it?
64
+ options:
65
+ - label: I am willing to open a pull request
66
+ required: false
67
+ - label: I have read CONTRIBUTING.md
68
+ required: false
@@ -0,0 +1,27 @@
1
+ ## What this changes
2
+
3
+ <!-- One or two sentences. What was wrong or missing, and what the change does. -->
4
+
5
+ ## Why
6
+
7
+ <!-- The reason, especially if the change looks like it does the wrong thing.
8
+ Behaviour that deliberately differs from the Go original needs a note here,
9
+ or a future reader will "fix" it back. -->
10
+
11
+ ## How it was verified
12
+
13
+ <!-- Delete what does not apply. -->
14
+
15
+ - [ ] `ruff check src tests examples` passes
16
+ - [ ] `python -m pytest` passes
17
+ - [ ] Checked against the Go implementation on a real site
18
+ (`DJ_REFERENCE_BIN=/tmp/dj-ref python -m pytest tests/test_differential.py`)
19
+ - [ ] New or changed behaviour has a test
20
+
21
+ ## Notes for review
22
+
23
+ <!-- Anything you want a reviewer to look at first, or are unsure about.
24
+
25
+ If this changes discovery, say whether it can *lose* a URL the previous
26
+ version found -- a port that silently stops finding a file is worse than one
27
+ that wastes a request. -->
@@ -0,0 +1,263 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ test:
18
+ name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
19
+ runs-on: ${{ matrix.os }}
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ # 3.10 is the floor declared in pyproject.toml; the rest guard against
24
+ # regressions on newer interpreters (a decompression bug once appeared
25
+ # only on 3.14).
26
+ python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
27
+ os: [ubuntu-latest]
28
+ include:
29
+ - { os: macos-latest, python-version: '3.13' }
30
+ - { os: windows-latest, python-version: '3.13' }
31
+
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+
35
+ - uses: actions/setup-python@v5
36
+ with:
37
+ python-version: ${{ matrix.python-version }}
38
+
39
+ - name: Install
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install -e '.[dev]'
43
+
44
+ - name: Show resolved HTTP stack
45
+ # curl-cffi is what enables TLS impersonation; knowing which version a
46
+ # failing run used makes a platform-specific failure much easier to read.
47
+ run: |
48
+ python -c "import curl_cffi, httpx, sys; print('python', sys.version.split()[0]); print('curl_cffi', curl_cffi.__version__); print('httpx', httpx.__version__)"
49
+
50
+ - name: Test
51
+ run: python -m pytest -q
52
+
53
+ verify-cli:
54
+ name: Verify CLI end to end
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+
59
+ - uses: actions/setup-python@v5
60
+ with:
61
+ python-version: '3.13'
62
+
63
+ - name: Install
64
+ run: |
65
+ python -m pip install --upgrade pip
66
+ pip install -e '.[dev]'
67
+
68
+ - name: Build a fixture site and scan it
69
+ # The unit tests cover the pipeline in-process; this checks the packaged
70
+ # entry point actually finds JS over real HTTP, which is the thing users
71
+ # invoke. Catches anything that only breaks in the console script.
72
+ run: |
73
+ python - <<'PY'
74
+ import json, pathlib, threading, subprocess, sys
75
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
76
+
77
+ root = pathlib.Path('fixture_site')
78
+ (root / 'js').mkdir(parents=True, exist_ok=True)
79
+ (root / 'index.html').write_text(
80
+ '<!doctype html><html><head>'
81
+ '<link rel="modulepreload" href="/js/vendor.js">'
82
+ '</head><body>'
83
+ '<script src="/js/app.js"></script>'
84
+ '<script>import("/js/lazy.js")</script>'
85
+ '</body></html>'
86
+ )
87
+ (root / 'js/app.js').write_text(
88
+ 'import("./chunk.js");\n//# sourceMappingURL=app.js.map\n'
89
+ )
90
+ for name in ('vendor.js', 'lazy.js', 'chunk.js'):
91
+ (root / 'js' / name).write_text('var x = 1;\n')
92
+ (root / 'js/app.js.map').write_text(json.dumps({
93
+ 'version': 3, 'sources': ['src/App.tsx'],
94
+ 'sourcesContent': ['export const App = 1;\n'], 'mappings': 'AAAA',
95
+ }))
96
+
97
+ class Handler(BaseHTTPRequestHandler):
98
+ def do_GET(self):
99
+ target = root / self.path.lstrip('/')
100
+ if target.is_dir():
101
+ target = target / 'index.html'
102
+ if not target.is_file():
103
+ self.send_response(404); self.send_header('Content-Length','0'); self.end_headers(); return
104
+ body = target.read_bytes()
105
+ ctype = 'application/json' if target.suffix == '.map' else (
106
+ 'text/html' if target.suffix == '.html' else 'application/javascript')
107
+ self.send_response(200)
108
+ self.send_header('Content-Type', ctype)
109
+ self.send_header('Content-Length', str(len(body)))
110
+ self.end_headers()
111
+ self.wfile.write(body)
112
+ def do_HEAD(self):
113
+ self.do_GET()
114
+ def log_message(self, *a): pass
115
+
116
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
117
+ threading.Thread(target=server.serve_forever, daemon=True).start()
118
+ url = f'http://127.0.0.1:{server.server_port}/'
119
+
120
+ proc = subprocess.run(
121
+ [sys.executable, '-m', 'pydj', '-f', 'json', '--no-cache', url],
122
+ capture_output=True, text=True, timeout=180,
123
+ )
124
+ server.shutdown(); server.server_close()
125
+
126
+ if proc.returncode != 0:
127
+ sys.exit(f'pydj exited {proc.returncode}\n{proc.stderr}')
128
+
129
+ data = json.loads(proc.stdout)
130
+ found = {u.rsplit('/', 1)[-1] for u in data['jsURLs']}
131
+ expected = {'app.js', 'vendor.js', 'lazy.js', 'chunk.js'}
132
+ missing = expected - found
133
+ if missing:
134
+ sys.exit(f'CLI missed {sorted(missing)}; found {sorted(found)}')
135
+
136
+ summary = data['summary']
137
+ assert summary['jsCount'] == 4, summary
138
+ assert summary.get('sourceMapCount') == 1, summary
139
+ assert summary.get('sourceCount') == 1, summary
140
+ print('CLI found all four JS files, the source map, and restored 1 source')
141
+ PY
142
+
143
+ differential:
144
+ name: Differential test against the Go implementation
145
+ runs-on: ubuntu-latest
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+
149
+ - uses: actions/setup-python@v5
150
+ with:
151
+ python-version: '3.13'
152
+
153
+ - uses: actions/setup-go@v5
154
+ with:
155
+ # The Go project declares 1.26; `stable` keeps this working when the
156
+ # action's cache has not seen that version yet.
157
+ go-version: 'stable'
158
+
159
+ - name: Build the reference implementation
160
+ # This is the oracle the port is measured against. The suite skips
161
+ # cleanly without it, which is why it needs its own job -- otherwise the
162
+ # strongest test in the repo never actually runs in CI.
163
+ #
164
+ # `continue-on-error` because this job depends on an external repository
165
+ # and Go toolchain: if the clone fails, the upstream module moves, or the
166
+ # Go version policy changes, that is not a defect in pydj and must not
167
+ # fail the build. The next step reports clearly whether the suite
168
+ # actually ran.
169
+ continue-on-error: true
170
+ run: |
171
+ git clone --depth 1 https://github.com/ejfkdev/dj /tmp/dj-src
172
+ cd /tmp/dj-src
173
+ go build -o /tmp/dj-ref .
174
+ /tmp/dj-ref -h | head -3
175
+
176
+ - name: Confirm the oracle is usable
177
+ # Without this, a failed clone would silently turn the differential suite
178
+ # into a no-op that still reports success -- the one outcome worse than
179
+ # not running it.
180
+ run: |
181
+ if [ ! -x /tmp/dj-ref ]; then
182
+ echo "::warning::reference binary unavailable; differential suite will skip"
183
+ exit 0
184
+ fi
185
+ python -c "import subprocess; out = subprocess.run(['/tmp/dj-ref','-h'], capture_output=True, text=True).stdout; assert 'Dynamic JS File Extractor' in out, out[:200]; print('oracle ok')"
186
+
187
+ - name: Install pydj
188
+ run: |
189
+ python -m pip install --upgrade pip
190
+ pip install -e '.[dev]'
191
+
192
+ - name: Run the differential suite
193
+ env:
194
+ DJ_REFERENCE_BIN: /tmp/dj-ref
195
+ run: python -m pytest tests/test_differential.py -v
196
+
197
+ - name: Fail if the suite silently skipped
198
+ # A skipped differential run looks identical to a passing one in the
199
+ # summary line, which is the failure mode this guards against.
200
+ run: |
201
+ if [ ! -x /tmp/dj-ref ]; then
202
+ echo "Differential suite did not run: no reference binary."
203
+ echo "This job is advisory; see the warning above."
204
+ exit 0
205
+ fi
206
+ python -m pytest tests/test_differential.py -q --no-header 2>&1 | tail -3
207
+
208
+ lint:
209
+ name: Lint
210
+ runs-on: ubuntu-latest
211
+ steps:
212
+ - uses: actions/checkout@v4
213
+
214
+ - uses: actions/setup-python@v5
215
+ with:
216
+ python-version: '3.13'
217
+
218
+ - name: Install
219
+ run: |
220
+ python -m pip install --upgrade pip
221
+ pip install -e '.[dev]' ruff
222
+
223
+ - name: Ruff
224
+ run: ruff check src tests
225
+
226
+ - name: Check the wheel builds and has the expected metadata
227
+ run: |
228
+ pip install build
229
+ python -m build --wheel
230
+ python - <<'PY'
231
+ import glob, zipfile
232
+ wheel = glob.glob('dist/*.whl')[0]
233
+ archive = zipfile.ZipFile(wheel)
234
+ # Locate the dist-info directory rather than reconstructing its name:
235
+ # the wheel filename carries build tags (py3-none-any) that the
236
+ # dist-info directory does not.
237
+ dist_info = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
238
+ meta = archive.read(dist_info).decode()
239
+
240
+ # curl-cffi must be an unconditional dependency: without it, TLS
241
+ # impersonation silently stops working, which is the whole product.
242
+ assert 'Requires-Dist: curl-cffi' in meta, 'curl-cffi must be a hard dependency'
243
+ assert 'Requires-Dist: httpx' in meta
244
+ assert 'Homepage, https://github.com/ejfkdev/pydj' in meta, 'wrong project URL'
245
+ assert 'Project-URL: Repository, https://github.com/ejfkdev/pydj' in meta
246
+ # PEP 639: the license is reported as an expression, not a free-text
247
+ # classifier. Asserting the old `License:` field would fail here even
248
+ # though the metadata is correct.
249
+ assert 'License-Expression: MPL-2.0' in meta, 'wrong license metadata'
250
+ assert 'License-File: LICENSE' in meta
251
+
252
+ entry_points = archive.read(
253
+ dist_info.replace('METADATA', 'entry_points.txt')
254
+ ).decode()
255
+ assert 'pydj = pydj.cli:main' in entry_points, 'pydj console script missing'
256
+ assert 'dj = pydj.cli:main' in entry_points, 'dj console script missing'
257
+
258
+ # The README is the PyPI description; an empty one means a packaging
259
+ # regression that only shows up on the project page.
260
+ assert 'Dynamic JS File Extractor' in meta, 'long_description missing'
261
+
262
+ print('wheel metadata OK:', dist_info.split('/')[0])
263
+ PY
@@ -0,0 +1,146 @@
1
+ name: Release
2
+
3
+ # Tag-driven, mirroring the Go project's build.yml: pushing `v0.2.0` is the only
4
+ # action required to cut a release.
5
+ on:
6
+ push:
7
+ tags:
8
+ - 'v*'
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ # Gate the release on the same checks CI runs. A tag that does not pass tests
15
+ # never reaches PyPI, where a bad upload can only be yanked, not deleted.
16
+ verify:
17
+ name: Verify before publishing
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.13'
25
+
26
+ - name: Install
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install -e '.[dev]'
30
+
31
+ - name: Test
32
+ run: python -m pytest -q
33
+
34
+ - name: Check the tag matches the packaged version
35
+ # A mismatch would publish a wheel whose version disagrees with its tag,
36
+ # and PyPI refuses re-uploads, so catch it here.
37
+ run: |
38
+ python - <<'PY'
39
+ import os, re, pathlib
40
+ tag = os.environ['GITHUB_REF_NAME'].lstrip('v')
41
+ text = pathlib.Path('pyproject.toml').read_text()
42
+ version = re.search(r'^version = "([^"]+)"', text, re.M).group(1)
43
+ init = pathlib.Path('src/pydj/__init__.py').read_text()
44
+ init_version = re.search(r'__version__ = "([^"]+)"', init).group(1)
45
+ if not (tag == version == init_version):
46
+ raise SystemExit(
47
+ f'version mismatch: tag={tag} pyproject={version} __init__={init_version}'
48
+ )
49
+ print(f'version {version} consistent across tag, pyproject.toml and __init__.py')
50
+ PY
51
+
52
+ build:
53
+ name: Build distributions
54
+ needs: verify
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+
59
+ - uses: actions/setup-python@v5
60
+ with:
61
+ python-version: '3.13'
62
+
63
+ - name: Build sdist and wheel
64
+ run: |
65
+ python -m pip install --upgrade pip build
66
+ python -m build
67
+
68
+ - name: Inspect the artefacts
69
+ run: |
70
+ python -m pip install twine
71
+ twine check dist/*
72
+ ls -la dist/
73
+
74
+ - uses: actions/upload-artifact@v4
75
+ with:
76
+ name: dist
77
+ path: dist/
78
+ retention-days: 7
79
+
80
+ pypi:
81
+ name: Publish to PyPI
82
+ needs: build
83
+ runs-on: ubuntu-latest
84
+ # Trusted publishing (OIDC) -- no long-lived token in the repository. Set
85
+ # this up once at https://pypi.org/manage/account/publishing/ with:
86
+ # owner: ejfkdev repo: pydj
87
+ # workflow: release.yml environment: pypi
88
+ # `id-token: write` is what mints the short-lived credential.
89
+ environment:
90
+ name: pypi
91
+ url: https://pypi.org/p/pydj
92
+ permissions:
93
+ id-token: write
94
+ steps:
95
+ - uses: actions/download-artifact@v4
96
+ with:
97
+ name: dist
98
+ path: dist/
99
+
100
+ - name: Publish
101
+ # Falls back to a PYPI_API_TOKEN secret if one is configured, so the
102
+ # workflow works whether or not trusted publishing has been set up yet.
103
+ # Prefer OIDC: nothing to rotate, and a leaked repo cannot publish.
104
+ uses: pypa/gh-action-pypi-publish@release/v1
105
+ with:
106
+ password: ${{ secrets.PYPI_API_TOKEN || '' }}
107
+ # Nothing to attach the attestations to otherwise.
108
+ attestations: ${{ secrets.PYPI_API_TOKEN == '' }}
109
+
110
+ github-release:
111
+ name: Create GitHub Release
112
+ needs: pypi
113
+ runs-on: ubuntu-latest
114
+ permissions:
115
+ contents: write
116
+ steps:
117
+ - uses: actions/checkout@v4
118
+ with:
119
+ fetch-depth: 0
120
+
121
+ # On a tag push, checkout overwrites the local tag ref with a bare commit
122
+ # SHA; re-fetching the annotated tag lets --notes-from-tag read its message.
123
+ - name: Fetch tag annotation
124
+ run: git fetch --force origin "+refs/tags/${{ github.ref_name }}:refs/tags/${{ github.ref_name }}"
125
+
126
+ - uses: actions/download-artifact@v4
127
+ with:
128
+ name: dist
129
+ path: dist/
130
+
131
+ - name: Create release
132
+ env:
133
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
134
+ run: |
135
+ gh release create "${{ github.ref_name }}" dist/* \
136
+ --title "${{ github.ref_name }}" \
137
+ --notes-from-tag \
138
+ --verify-tag
139
+
140
+ - name: Announce the install command
141
+ run: |
142
+ echo "Published. Install with:" >> "$GITHUB_STEP_SUMMARY"
143
+ echo '```bash' >> "$GITHUB_STEP_SUMMARY"
144
+ echo "pipx install pydj" >> "$GITHUB_STEP_SUMMARY"
145
+ echo "uvx pydj --help" >> "$GITHUB_STEP_SUMMARY"
146
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
pydj-0.1.0/.gitignore ADDED
@@ -0,0 +1,70 @@
1
+ # Byte-compiled / cache
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Distribution / packaging
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ share/python-wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+ MANIFEST
26
+ pip-wheel-metadata/
27
+
28
+ # Virtual environments
29
+ .venv/
30
+ venv/
31
+ ENV/
32
+ env/
33
+ env.bak/
34
+ venv.bak/
35
+
36
+ # Test / coverage artefacts
37
+ .pytest_cache/
38
+ .tox/
39
+ .nox/
40
+ .coverage
41
+ .coverage.*
42
+ coverage.xml
43
+ *.cover
44
+ .hypothesis/
45
+ htmlcov/
46
+
47
+ # Type checkers / linters
48
+ .mypy_cache/
49
+ .dmypy.json
50
+ .pyre/
51
+ .pytype/
52
+ .ruff_cache/
53
+
54
+ # Build tool caches
55
+ .uv/
56
+
57
+ # Editors / OS
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+ *~
63
+ .DS_Store
64
+ Thumbs.db
65
+
66
+ # Generated by examples/06_batch_scan.py
67
+ batch_results.json
68
+
69
+ # A Go checkout used as the differential oracle
70
+ /dj-ref