shellscripts 0.23.1__tar.gz → 0.24.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shellscripts-0.23.1 → shellscripts-0.24.1}/PKG-INFO +2 -2
- {shellscripts-0.23.1 → shellscripts-0.24.1}/README.md +1 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/pyproject.toml +1 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/__init__.py +1 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_split_by_format.py +44 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_split_by_toc.py +44 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/version_check.py +6 -5
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shellscripts.egg-info/PKG-INFO +2 -2
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shellscripts.egg-info/SOURCES.txt +3 -1
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_007_pdf_commands.py +156 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_014_pypi_version_source.py +49 -5
- shellscripts-0.24.1/tests/test_tst_015_pre_release_check.py +149 -0
- shellscripts-0.24.1/tests/test_tst_016_release_precheck_gating.py +151 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/LICENSE +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/setup.cfg +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/__main__.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/__init__.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/_dc_common.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/ai_install.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/bin_links.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/claude.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/clean.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/codex.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/copilot.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/dicom2jpg.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/dicomviewer.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/diff_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/doxygen_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/edit_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/gemini.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/kiro.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/opencode.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_crop.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_merge.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_tiler_090.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_tiler_100.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_toc_clean.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pi.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/req_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/tests_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/ubuntu_dark_theme.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/venv_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/video2h264.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/video2h265.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/view_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/vscode_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/vsinsider_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/config.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/core.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/utils.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shellscripts.egg-info/dependency_links.txt +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shellscripts.egg-info/entry_points.txt +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/src/shellscripts.egg-info/top_level.txt +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_001_002_core.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_003_ai_install.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_004_bin_links_clean.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_005_cli_launchers.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_006_dc_common.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_008_env_commands.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_010_req_cmd.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_011_video_commands.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_012_workflow_runtime.py +0 -0
- {shellscripts-0.23.1 → shellscripts-0.24.1}/tests/test_tst_013_release_pypi_gating.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shellscripts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.1
|
|
4
4
|
Summary: Execute useful shell scripts via an uvx-compatible CLI interface.
|
|
5
5
|
Author: Francesco Rolando
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -31,7 +31,7 @@ Dynamic: license-file
|
|
|
31
31
|
> * **No Support or Feature Requests:** Please **do not** open issues, bug reports, or feature requests. They will not be addressed or accepted at this stage of development.
|
|
32
32
|
> * **No Warranty:** This software is provided **"AS IS"**, without warranty of any kind, express or implied. You may use it strictly for **testing and evaluation purposes** at your own sole risk.
|
|
33
33
|
|
|
34
|
-
# s/shellScripts (0.
|
|
34
|
+
# s/shellScripts (0.24.1)
|
|
35
35
|
|
|
36
36
|
<p align="center">
|
|
37
37
|
<img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
> * **No Support or Feature Requests:** Please **do not** open issues, bug reports, or feature requests. They will not be addressed or accepted at this stage of development.
|
|
11
11
|
> * **No Warranty:** This software is provided **"AS IS"**, without warranty of any kind, express or implied. You may use it strictly for **testing and evaluation purposes** at your own sole risk.
|
|
12
12
|
|
|
13
|
-
# s/shellScripts (0.
|
|
13
|
+
# s/shellScripts (0.24.1)
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
{shellscripts-0.23.1 → shellscripts-0.24.1}/src/shell_scripts/commands/pdf_split_by_format.py
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import errno
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
5
|
+
import shutil
|
|
4
6
|
import subprocess
|
|
5
7
|
import tempfile
|
|
6
8
|
|
|
@@ -102,7 +104,48 @@ def _extract_toc_for_range(data_file, start, end):
|
|
|
102
104
|
return "\n".join(result)
|
|
103
105
|
|
|
104
106
|
|
|
107
|
+
def _promote_temp_output(temp_output, output_file):
|
|
108
|
+
"""
|
|
109
|
+
@brief Promote one pdftk-produced temporary artifact onto the final output path.
|
|
110
|
+
@details Attempts same-device atomic replacement via `os.replace`. On
|
|
111
|
+
`OSError` with `errno.EXDEV` (temporary directory and output directory
|
|
112
|
+
located on different filesystems), falls back to `shutil.move`, which
|
|
113
|
+
copies the artifact content and removes the temporary source. All other
|
|
114
|
+
`OSError` values propagate unchanged to preserve pre-existing failure
|
|
115
|
+
semantics. Time complexity O(1) for same-device renames, O(n) over file
|
|
116
|
+
size for the cross-device copy fallback.
|
|
117
|
+
@param temp_output {str} Temporary artifact path produced by the external tool chain.
|
|
118
|
+
@param output_file {str} Final chunk path that must receive the artifact content.
|
|
119
|
+
@return {None} Applies destination filesystem mutation.
|
|
120
|
+
@throws {OSError} Propagated for non-EXDEV replacement failures.
|
|
121
|
+
@satisfies REQ-031, REQ-032
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
os.replace(temp_output, output_file)
|
|
125
|
+
except OSError as error:
|
|
126
|
+
if error.errno != errno.EXDEV:
|
|
127
|
+
raise
|
|
128
|
+
shutil.move(temp_output, output_file)
|
|
129
|
+
|
|
130
|
+
|
|
105
131
|
def _apply_toc(output_file, toc_content):
|
|
132
|
+
"""
|
|
133
|
+
@brief Reapply in-range TOC data to one generated chunk file.
|
|
134
|
+
@details Writes `toc_content` into a temporary `.info` payload, prepares
|
|
135
|
+
temporary PDF artifacts, strips object streams from `output_file` via
|
|
136
|
+
`qpdf`, rebuilds the chunk with TOC metadata via `pdftk update_info`,
|
|
137
|
+
and promotes the artifact onto `output_file` through
|
|
138
|
+
`_promote_temp_output` so a temporary directory on a different
|
|
139
|
+
filesystem cannot silently drop the chunk TOC. Tool-chain failures are
|
|
140
|
+
contained without aborting the split loop. Time complexity O(m) over
|
|
141
|
+
TOC content size plus external process latency.
|
|
142
|
+
@param output_file {str} Generated chunk path receiving the TOC metadata.
|
|
143
|
+
@param toc_content {str} Range-filtered pdftk dump-data bookmark payload.
|
|
144
|
+
@return {None} Mutates `output_file` content on success.
|
|
145
|
+
@throws {OSError} Propagated when temporary payload creation, unlink, or
|
|
146
|
+
non-EXDEV promotion fails.
|
|
147
|
+
@satisfies REQ-032
|
|
148
|
+
"""
|
|
106
149
|
if not toc_content.strip():
|
|
107
150
|
return
|
|
108
151
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".info", delete=False) as f:
|
|
@@ -124,7 +167,7 @@ def _apply_toc(output_file, toc_content):
|
|
|
124
167
|
capture_output=True,
|
|
125
168
|
)
|
|
126
169
|
if r2.returncode == 0:
|
|
127
|
-
|
|
170
|
+
_promote_temp_output(with_toc, output_file)
|
|
128
171
|
else:
|
|
129
172
|
os.unlink(with_toc)
|
|
130
173
|
print(f" [!] Warning: unable to update TOC for '{output_file}'.")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import errno
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
5
|
+
import shutil
|
|
4
6
|
import subprocess
|
|
5
7
|
import tempfile
|
|
6
8
|
|
|
@@ -76,7 +78,48 @@ def _sanitize_title(title):
|
|
|
76
78
|
return safe.strip("-")
|
|
77
79
|
|
|
78
80
|
|
|
81
|
+
def _promote_temp_output(temp_output, output_file):
|
|
82
|
+
"""
|
|
83
|
+
@brief Promote one pdftk-produced temporary artifact onto the final output path.
|
|
84
|
+
@details Attempts same-device atomic replacement via `os.replace`. On
|
|
85
|
+
`OSError` with `errno.EXDEV` (temporary directory and output directory
|
|
86
|
+
located on different filesystems), falls back to `shutil.move`, which
|
|
87
|
+
copies the artifact content and removes the temporary source. All other
|
|
88
|
+
`OSError` values propagate unchanged to preserve pre-existing failure
|
|
89
|
+
semantics. Time complexity O(1) for same-device renames, O(n) over file
|
|
90
|
+
size for the cross-device copy fallback.
|
|
91
|
+
@param temp_output {str} Temporary artifact path produced by the external tool chain.
|
|
92
|
+
@param output_file {str} Final chunk path that must receive the artifact content.
|
|
93
|
+
@return {None} Applies destination filesystem mutation.
|
|
94
|
+
@throws {OSError} Propagated for non-EXDEV replacement failures.
|
|
95
|
+
@satisfies REQ-031, REQ-032
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
os.replace(temp_output, output_file)
|
|
99
|
+
except OSError as error:
|
|
100
|
+
if error.errno != errno.EXDEV:
|
|
101
|
+
raise
|
|
102
|
+
shutil.move(temp_output, output_file)
|
|
103
|
+
|
|
104
|
+
|
|
79
105
|
def _apply_toc_to_file(output_file, toc_content):
|
|
106
|
+
"""
|
|
107
|
+
@brief Reapply rebased TOC data to one generated chunk file.
|
|
108
|
+
@details Writes `toc_content` into a temporary `.info` payload, strips
|
|
109
|
+
object streams from `output_file` via `qpdf`, rebuilds the chunk with
|
|
110
|
+
TOC metadata via `pdftk update_info`, and promotes the artifact onto
|
|
111
|
+
`output_file` through `_promote_temp_output` so a temporary directory
|
|
112
|
+
on a different filesystem cannot crash chunk generation. Emits a
|
|
113
|
+
warning and keeps the original chunk when the tool chain fails.
|
|
114
|
+
Time complexity O(m) over TOC content size plus external process
|
|
115
|
+
latency.
|
|
116
|
+
@param output_file {str} Generated chunk path receiving the TOC metadata.
|
|
117
|
+
@param toc_content {str} Rebased pdftk dump-data bookmark payload.
|
|
118
|
+
@return {None} Mutates `output_file` content on success.
|
|
119
|
+
@throws {OSError} Propagated when temporary payload creation, unlink, or
|
|
120
|
+
non-EXDEV promotion fails.
|
|
121
|
+
@satisfies REQ-031
|
|
122
|
+
"""
|
|
80
123
|
if not toc_content.strip():
|
|
81
124
|
return
|
|
82
125
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".info", delete=False) as f:
|
|
@@ -95,7 +138,7 @@ def _apply_toc_to_file(output_file, toc_content):
|
|
|
95
138
|
capture_output=True,
|
|
96
139
|
)
|
|
97
140
|
if r2.returncode == 0:
|
|
98
|
-
|
|
141
|
+
_promote_temp_output(with_toc, output_file)
|
|
99
142
|
else:
|
|
100
143
|
if os.path.exists(with_toc):
|
|
101
144
|
os.unlink(with_toc)
|
|
@@ -263,11 +263,12 @@ def print_remote_latest_versions(current_version: str) -> None:
|
|
|
263
263
|
|
|
264
264
|
@details Emits one `<source> latest: <version>` line per remote source in
|
|
265
265
|
fixed order (PyPI, GitHub). A failed source lookup prints one bright-red
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
`<source> latest: unknown (<reason>)` line where `<reason>` is the
|
|
267
|
+
`_format_request_error` descriptor of the actual failure, and the remaining
|
|
268
|
+
source is still processed. Complexity O(1) excluding network latency.
|
|
268
269
|
@param current_version {str} Installed package version used in User-Agent.
|
|
269
270
|
@return {None} Writes two report or error lines to stdout.
|
|
270
|
-
@satisfies REQ-003, REQ-075, DES-020
|
|
271
|
+
@satisfies REQ-003, REQ-075, REQ-084, DES-020
|
|
271
272
|
"""
|
|
272
273
|
|
|
273
274
|
for source_label, fetch_version in (
|
|
@@ -278,8 +279,8 @@ def print_remote_latest_versions(current_version: str) -> None:
|
|
|
278
279
|
latest_version = fetch_version(current_version).lstrip("v")
|
|
279
280
|
except Exception as error:
|
|
280
281
|
print(
|
|
281
|
-
f"{BRIGHT_RED}{source_label} latest
|
|
282
|
-
f"{_format_request_error(error)}
|
|
282
|
+
f"{BRIGHT_RED}{source_label} latest: unknown "
|
|
283
|
+
f"({_format_request_error(error)}){RESET}"
|
|
283
284
|
)
|
|
284
285
|
continue
|
|
285
286
|
print(f"{source_label} latest: {latest_version}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shellscripts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.1
|
|
4
4
|
Summary: Execute useful shell scripts via an uvx-compatible CLI interface.
|
|
5
5
|
Author: Francesco Rolando
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -31,7 +31,7 @@ Dynamic: license-file
|
|
|
31
31
|
> * **No Support or Feature Requests:** Please **do not** open issues, bug reports, or feature requests. They will not be addressed or accepted at this stage of development.
|
|
32
32
|
> * **No Warranty:** This software is provided **"AS IS"**, without warranty of any kind, express or implied. You may use it strictly for **testing and evaluation purposes** at your own sole risk.
|
|
33
33
|
|
|
34
|
-
# s/shellScripts (0.
|
|
34
|
+
# s/shellScripts (0.24.1)
|
|
35
35
|
|
|
36
36
|
<p align="center">
|
|
37
37
|
<img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
|
@@ -56,4 +56,6 @@ tests/test_tst_010_req_cmd.py
|
|
|
56
56
|
tests/test_tst_011_video_commands.py
|
|
57
57
|
tests/test_tst_012_workflow_runtime.py
|
|
58
58
|
tests/test_tst_013_release_pypi_gating.py
|
|
59
|
-
tests/test_tst_014_pypi_version_source.py
|
|
59
|
+
tests/test_tst_014_pypi_version_source.py
|
|
60
|
+
tests/test_tst_015_pre_release_check.py
|
|
61
|
+
tests/test_tst_016_release_precheck_gating.py
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
@return {None} Pytest module scope.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import errno
|
|
10
11
|
import pathlib
|
|
11
12
|
import subprocess
|
|
12
13
|
|
|
@@ -206,6 +207,7 @@ def test_pdf_split_by_toc_runs_required_tools(monkeypatch, tmp_path):
|
|
|
206
207
|
monkeypatch.setattr(pdf_split_by_toc, "require_commands", lambda *_cmds: None)
|
|
207
208
|
monkeypatch.setattr(pdf_split_by_toc.subprocess, "run", _fake_run)
|
|
208
209
|
|
|
210
|
+
monkeypatch.chdir(tmp_path)
|
|
209
211
|
result = pdf_split_by_toc.run([str(input_pdf)])
|
|
210
212
|
|
|
211
213
|
assert result == 0
|
|
@@ -213,6 +215,79 @@ def test_pdf_split_by_toc_runs_required_tools(monkeypatch, tmp_path):
|
|
|
213
215
|
assert any(cmd[0] == "pdftk" for cmd in calls)
|
|
214
216
|
|
|
215
217
|
|
|
218
|
+
def test_pdf_split_by_toc_promotes_output_across_devices(monkeypatch, tmp_path):
|
|
219
|
+
"""
|
|
220
|
+
@brief Validate cross-device temp-output promotion in pdf-split-by-toc.
|
|
221
|
+
@details Simulates `os.replace` failing with `errno.EXDEV` (system temp
|
|
222
|
+
directory located on a different filesystem than the working directory)
|
|
223
|
+
and asserts the generated chunk still receives the pdftk-produced
|
|
224
|
+
artifact through the copy-based fallback instead of crashing.
|
|
225
|
+
@param monkeypatch {pytest.MonkeyPatch} Runtime patch helper.
|
|
226
|
+
@param tmp_path {pathlib.Path} Isolated filesystem fixture.
|
|
227
|
+
@return {None} Assertions only.
|
|
228
|
+
@satisfies TST-007, REQ-031
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
input_pdf = tmp_path / "book.pdf"
|
|
232
|
+
input_pdf.write_text("dummy")
|
|
233
|
+
toc_artifact = (
|
|
234
|
+
"NumberOfPages: 3\n"
|
|
235
|
+
"BookmarkBegin\n"
|
|
236
|
+
"BookmarkTitle: Intro\n"
|
|
237
|
+
"BookmarkLevel: 1\n"
|
|
238
|
+
"BookmarkPageNumber: 1\n"
|
|
239
|
+
"BookmarkBegin\n"
|
|
240
|
+
"BookmarkTitle: End\n"
|
|
241
|
+
"BookmarkLevel: 1\n"
|
|
242
|
+
"BookmarkPageNumber: 3\n"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _fake_run(command, **kwargs):
|
|
246
|
+
"""
|
|
247
|
+
@brief Mock subprocess.run for pdf-split-by-toc.
|
|
248
|
+
@details Captures invocation list and writes the deterministic TOC
|
|
249
|
+
artifact to every pdftk output target so the promotion result stays
|
|
250
|
+
observable.
|
|
251
|
+
@param command {list[str]} Command token vector.
|
|
252
|
+
@param kwargs {dict[str, object]} Execution options.
|
|
253
|
+
@return {subprocess.CompletedProcess[str]} Successful completion object.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
del kwargs
|
|
257
|
+
if command[0] == "pdftk" and "output" in command:
|
|
258
|
+
output_path = command[command.index("output") + 1]
|
|
259
|
+
with open(output_path, "w", encoding="utf-8") as handle:
|
|
260
|
+
handle.write(toc_artifact)
|
|
261
|
+
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
|
262
|
+
|
|
263
|
+
def _raise_exdev(source, destination):
|
|
264
|
+
"""
|
|
265
|
+
@brief Simulate cross-device rename rejection.
|
|
266
|
+
@details Replaces `os.replace` with the exact `OSError` raised by the
|
|
267
|
+
operating system when source and destination live on different
|
|
268
|
+
filesystems, keeping the reproducer independent from the host
|
|
269
|
+
mount layout.
|
|
270
|
+
@param source {str} Source path token.
|
|
271
|
+
@param destination {str} Destination path token.
|
|
272
|
+
@return {None} Never returns normally.
|
|
273
|
+
@throws {OSError} Always raises with `errno.EXDEV`.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
del source, destination
|
|
277
|
+
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
|
278
|
+
|
|
279
|
+
monkeypatch.setattr(pdf_split_by_toc, "require_commands", lambda *_cmds: None)
|
|
280
|
+
monkeypatch.setattr(pdf_split_by_toc.subprocess, "run", _fake_run)
|
|
281
|
+
monkeypatch.setattr(pdf_split_by_toc.os, "replace", _raise_exdev)
|
|
282
|
+
|
|
283
|
+
monkeypatch.chdir(tmp_path)
|
|
284
|
+
result = pdf_split_by_toc.run([str(input_pdf)])
|
|
285
|
+
|
|
286
|
+
chunk = input_pdf.parent / "book_01_Intro.pdf"
|
|
287
|
+
assert result == 0
|
|
288
|
+
assert chunk.read_text(encoding="utf-8") == toc_artifact
|
|
289
|
+
|
|
290
|
+
|
|
216
291
|
def test_pdf_split_by_format_runs_required_tools(monkeypatch, tmp_path):
|
|
217
292
|
"""
|
|
218
293
|
@brief Validate pdf-split-by-format command sequence.
|
|
@@ -251,12 +326,93 @@ def test_pdf_split_by_format_runs_required_tools(monkeypatch, tmp_path):
|
|
|
251
326
|
monkeypatch.setattr(pdf_split_by_format, "_has_toc", lambda _pdf: False)
|
|
252
327
|
monkeypatch.setattr(pdf_split_by_format.subprocess, "run", _fake_run)
|
|
253
328
|
|
|
329
|
+
monkeypatch.chdir(tmp_path)
|
|
254
330
|
result = pdf_split_by_format.run([str(input_pdf)])
|
|
255
331
|
|
|
256
332
|
assert result == 0
|
|
257
333
|
assert any(cmd[0] == "qpdf" for cmd in calls)
|
|
258
334
|
|
|
259
335
|
|
|
336
|
+
def test_pdf_split_by_format_promotes_output_across_devices(monkeypatch, tmp_path):
|
|
337
|
+
"""
|
|
338
|
+
@brief Validate cross-device temp-output promotion in pdf-split-by-format.
|
|
339
|
+
@details Simulates `os.replace` failing with `errno.EXDEV` while TOC data
|
|
340
|
+
is available and asserts the generated chunk still receives the
|
|
341
|
+
pdftk-produced artifact through the copy-based fallback instead of
|
|
342
|
+
silently losing its table of contents.
|
|
343
|
+
@param monkeypatch {pytest.MonkeyPatch} Runtime patch helper.
|
|
344
|
+
@param tmp_path {pathlib.Path} Isolated filesystem fixture.
|
|
345
|
+
@return {None} Assertions only.
|
|
346
|
+
@satisfies TST-007, REQ-032
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
input_pdf = tmp_path / "formats.pdf"
|
|
350
|
+
input_pdf.write_text("dummy")
|
|
351
|
+
toc_artifact = (
|
|
352
|
+
"NumberOfPages: 3\n"
|
|
353
|
+
"BookmarkBegin\n"
|
|
354
|
+
"BookmarkTitle: Intro\n"
|
|
355
|
+
"BookmarkLevel: 1\n"
|
|
356
|
+
"BookmarkPageNumber: 1\n"
|
|
357
|
+
"BookmarkBegin\n"
|
|
358
|
+
"BookmarkTitle: End\n"
|
|
359
|
+
"BookmarkLevel: 1\n"
|
|
360
|
+
"BookmarkPageNumber: 3\n"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _fake_run(command, **kwargs):
|
|
364
|
+
"""
|
|
365
|
+
@brief Mock subprocess.run for pdf-split-by-format.
|
|
366
|
+
@details Captures invocation list and writes the deterministic TOC
|
|
367
|
+
artifact to every pdftk output target (dump-data file and
|
|
368
|
+
update-info output) so the promotion result stays observable.
|
|
369
|
+
@param command {list[str]} Command token vector.
|
|
370
|
+
@param kwargs {dict[str, object]} Execution options.
|
|
371
|
+
@return {subprocess.CompletedProcess[str]} Successful completion object.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
del kwargs
|
|
375
|
+
if command[0] == "pdftk" and "output" in command:
|
|
376
|
+
output_path = command[command.index("output") + 1]
|
|
377
|
+
with open(output_path, "w", encoding="utf-8") as handle:
|
|
378
|
+
handle.write(toc_artifact)
|
|
379
|
+
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
|
380
|
+
|
|
381
|
+
def _raise_exdev(source, destination):
|
|
382
|
+
"""
|
|
383
|
+
@brief Simulate cross-device rename rejection.
|
|
384
|
+
@details Replaces `os.replace` with the exact `OSError` raised by the
|
|
385
|
+
operating system when source and destination live on different
|
|
386
|
+
filesystems, keeping the reproducer independent from the host
|
|
387
|
+
mount layout.
|
|
388
|
+
@param source {str} Source path token.
|
|
389
|
+
@param destination {str} Destination path token.
|
|
390
|
+
@return {None} Never returns normally.
|
|
391
|
+
@throws {OSError} Always raises with `errno.EXDEV`.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
del source, destination
|
|
395
|
+
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
|
396
|
+
|
|
397
|
+
monkeypatch.setattr(pdf_split_by_format, "require_commands", lambda *_cmds: None)
|
|
398
|
+
monkeypatch.setattr(pdf_split_by_format, "_get_total_pages", lambda _pdf: 3)
|
|
399
|
+
monkeypatch.setattr(
|
|
400
|
+
pdf_split_by_format,
|
|
401
|
+
"_get_page_formats",
|
|
402
|
+
lambda _pdf, _pages: ["A4", "A4", "A3"],
|
|
403
|
+
)
|
|
404
|
+
monkeypatch.setattr(pdf_split_by_format, "_has_toc", lambda _pdf: True)
|
|
405
|
+
monkeypatch.setattr(pdf_split_by_format.subprocess, "run", _fake_run)
|
|
406
|
+
monkeypatch.setattr(pdf_split_by_format.os, "replace", _raise_exdev)
|
|
407
|
+
|
|
408
|
+
monkeypatch.chdir(tmp_path)
|
|
409
|
+
result = pdf_split_by_format.run([str(input_pdf)])
|
|
410
|
+
|
|
411
|
+
chunk = input_pdf.parent / "formats_01.pdf"
|
|
412
|
+
assert result == 0
|
|
413
|
+
assert chunk.read_text(encoding="utf-8") == toc_artifact
|
|
414
|
+
|
|
415
|
+
|
|
260
416
|
def test_pdf_toc_clean_generates_toc_clean_filename(monkeypatch, tmp_path):
|
|
261
417
|
"""
|
|
262
418
|
@brief Validate pdf-toc-clean output naming and tool sequence.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import json
|
|
16
16
|
import types
|
|
17
17
|
import urllib.error
|
|
18
|
+
from email.message import Message
|
|
18
19
|
|
|
19
20
|
import shell_scripts.core as core
|
|
20
21
|
from shell_scripts.config import get_default_runtime_config
|
|
@@ -204,12 +205,12 @@ def test_print_remote_latest_versions_reports_failed_source_and_continues(
|
|
|
204
205
|
"""
|
|
205
206
|
@brief Validate per-source error reporting during version report.
|
|
206
207
|
@details Forces the PyPI lookup to raise a non-HTTP exception and asserts
|
|
207
|
-
|
|
208
|
-
emitted.
|
|
208
|
+
the bright-red `PyPI latest: unknown (<reason>)` failure line while the
|
|
209
|
+
GitHub line is still emitted.
|
|
209
210
|
@param monkeypatch {pytest.MonkeyPatch} Runtime patch helper.
|
|
210
211
|
@param capsys {pytest.CaptureFixture[str]} Stdout/stderr capture helper.
|
|
211
212
|
@return {None} Assertions only.
|
|
212
|
-
@satisfies TST-014, DES-020
|
|
213
|
+
@satisfies TST-014, REQ-084, DES-020
|
|
213
214
|
"""
|
|
214
215
|
|
|
215
216
|
def _raise_url_error(_version):
|
|
@@ -229,11 +230,54 @@ def test_print_remote_latest_versions_reports_failed_source_and_continues(
|
|
|
229
230
|
version_check.print_remote_latest_versions("0.1.0")
|
|
230
231
|
captured = capsys.readouterr()
|
|
231
232
|
|
|
232
|
-
|
|
233
|
-
assert
|
|
233
|
+
expected_output = "PyPI latest: unknown (URLError: <urlopen error pypi unreachable>)"
|
|
234
|
+
assert expected_output in captured.out
|
|
235
|
+
assert "PyPI latest check failed" not in captured.out
|
|
234
236
|
assert "GitHub latest: 0.1.0" in captured.out
|
|
235
237
|
|
|
236
238
|
|
|
239
|
+
def test_print_remote_latest_versions_reports_github_http_404(
|
|
240
|
+
monkeypatch,
|
|
241
|
+
capsys,
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
@brief Validate GitHub HTTP failure rendering during version report.
|
|
245
|
+
@details Forces the GitHub lookup to raise an HTTP 404 error and asserts
|
|
246
|
+
the bright-red `GitHub latest: unknown (HTTP 404)` failure line while
|
|
247
|
+
the PyPI line is still emitted.
|
|
248
|
+
@param monkeypatch {pytest.MonkeyPatch} Runtime patch helper.
|
|
249
|
+
@param capsys {pytest.CaptureFixture[str]} Stdout/stderr capture helper.
|
|
250
|
+
@return {None} Assertions only.
|
|
251
|
+
@satisfies TST-014, REQ-084, DES-020
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def _raise_http_404(_version):
|
|
255
|
+
"""
|
|
256
|
+
@brief Simulate GitHub API HTTP 404 failure.
|
|
257
|
+
@param _version {str} Unused installed version.
|
|
258
|
+
@return {None} Always raises.
|
|
259
|
+
@throws {urllib.error.HTTPError} Deterministic HTTP failure.
|
|
260
|
+
"""
|
|
261
|
+
raise urllib.error.HTTPError(
|
|
262
|
+
version_check.GITHUB_API_URL, 404, "Not Found", Message(), None
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
monkeypatch.setattr(
|
|
266
|
+
version_check, "fetch_pypi_latest_version", lambda _version: "0.1.0"
|
|
267
|
+
)
|
|
268
|
+
monkeypatch.setattr(
|
|
269
|
+
version_check, "fetch_github_latest_release", _raise_http_404
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
version_check.print_remote_latest_versions("0.1.0")
|
|
273
|
+
captured = capsys.readouterr()
|
|
274
|
+
|
|
275
|
+
expected_output = "GitHub latest: unknown (HTTP 404)"
|
|
276
|
+
assert expected_output in captured.out
|
|
277
|
+
assert "GitHub latest check failed" not in captured.out
|
|
278
|
+
assert "PyPI latest: 0.1.0" in captured.out
|
|
279
|
+
|
|
280
|
+
|
|
237
281
|
def test_main_version_prints_installed_pypi_and_github_lines(
|
|
238
282
|
monkeypatch,
|
|
239
283
|
capsys,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@brief Validate pre-release check script mandatory structure and syntax.
|
|
3
|
+
@details Parses `scripts/pre-release-check.sh` and asserts repository-root
|
|
4
|
+
resolution, pytest invocation with `PYTHONPATH=src` and `.venv` fallback,
|
|
5
|
+
`dist` cleanup, `uv build`/`python3 -m build` fallback, sdist and wheel
|
|
6
|
+
artifact gates, fail-fast exit semantics, and `PASS`/`FAIL` evidence-line
|
|
7
|
+
format. Validates Bash syntax via `bash -n` without executing the release
|
|
8
|
+
pipeline.
|
|
9
|
+
@satisfies TST-015, DES-021, DES-022, DES-023, DES-024, DES-025, DES-026,
|
|
10
|
+
DES-027, REQ-079, REQ-080, REQ-081, REQ-082
|
|
11
|
+
@return {None} Pytest module scope.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pathlib
|
|
15
|
+
import subprocess
|
|
16
|
+
|
|
17
|
+
SCRIPT_PATH = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "pre-release-check.sh"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_script() -> str:
|
|
21
|
+
"""
|
|
22
|
+
@brief Load the pre-release check script content.
|
|
23
|
+
@details Reads the script file as UTF-8 text for token-level structural
|
|
24
|
+
assertions. Time complexity O(n) over script size.
|
|
25
|
+
@return {str} Full script content.
|
|
26
|
+
@throws {OSError} When the script file cannot be read.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
return SCRIPT_PATH.read_text(encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_pre_release_script_exists_and_is_non_empty():
|
|
33
|
+
"""
|
|
34
|
+
@brief Validate pre-release script presence at the required path.
|
|
35
|
+
@details Asserts the script file exists at `scripts/pre-release-check.sh`
|
|
36
|
+
and contains non-empty content, proving the mandatory pipeline
|
|
37
|
+
entrypoint is implemented.
|
|
38
|
+
@return {None} Assertions only.
|
|
39
|
+
@satisfies TST-015, DES-021
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
assert SCRIPT_PATH.is_file()
|
|
43
|
+
assert _read_script().strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_pre_release_script_passes_bash_syntax_check():
|
|
47
|
+
"""
|
|
48
|
+
@brief Validate Bash syntax of the pre-release script.
|
|
49
|
+
@details Executes `bash -n` against the script and passes only when the
|
|
50
|
+
parser reports no syntax errors and a zero exit status.
|
|
51
|
+
@return {None} Assertions only.
|
|
52
|
+
@satisfies TST-015, DES-021
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["bash", "-n", str(SCRIPT_PATH)],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
)
|
|
60
|
+
assert result.returncode == 0, result.stderr
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_pre_release_script_resolves_repository_root():
|
|
64
|
+
"""
|
|
65
|
+
@brief Validate repository-root resolution and execution directory.
|
|
66
|
+
@details Asserts the script resolves the Git repository root via
|
|
67
|
+
`git rev-parse --show-toplevel` and executes all checks from that root
|
|
68
|
+
via an explicit working-directory change.
|
|
69
|
+
@return {None} Assertions only.
|
|
70
|
+
@satisfies TST-015, DES-022
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
content = _read_script()
|
|
74
|
+
assert "rev-parse --show-toplevel" in content
|
|
75
|
+
assert 'cd -- "${PROJECT_ROOT}"' in content
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_pre_release_script_runs_pytest_with_src_pythonpath():
|
|
79
|
+
"""
|
|
80
|
+
@brief Validate mandatory unit-test check invocation.
|
|
81
|
+
@details Asserts the script runs pytest against `tests/`, prepends the
|
|
82
|
+
project `src` directory to `PYTHONPATH`, and selects the interpreter
|
|
83
|
+
via the `.venv/bin/python3`-when-present fallback to `python3`.
|
|
84
|
+
@return {None} Assertions only.
|
|
85
|
+
@satisfies TST-015, DES-023
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
content = _read_script()
|
|
89
|
+
assert "PYTHONPATH=" in content
|
|
90
|
+
assert "${PWD}/src" in content
|
|
91
|
+
assert "-m pytest tests/" in content
|
|
92
|
+
assert ".venv/bin/python3" in content
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_pre_release_script_builds_and_verifies_artifacts():
|
|
96
|
+
"""
|
|
97
|
+
@brief Validate mandatory build and artifact verification checks.
|
|
98
|
+
@details Asserts the script removes the preexisting `dist` directory,
|
|
99
|
+
builds via `uv build` with `python3 -m build` fallback, and gates the
|
|
100
|
+
pipeline on at least one sdist and one wheel artifact in `dist`.
|
|
101
|
+
@return {None} Assertions only.
|
|
102
|
+
@satisfies TST-015, DES-024, DES-025
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
content = _read_script()
|
|
106
|
+
assert "rm -rf -- dist" in content
|
|
107
|
+
assert "uv build" in content
|
|
108
|
+
assert "python3 -m build" in content
|
|
109
|
+
assert 'compgen -G "dist/*.tar.gz"' in content
|
|
110
|
+
assert 'compgen -G "dist/*.whl"' in content
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_pre_release_script_enforces_fail_fast_and_evidence_lines():
|
|
114
|
+
"""
|
|
115
|
+
@brief Validate fail-fast semantics and evidence-line format.
|
|
116
|
+
@details Asserts the script prints `PASS:` and `FAIL:` evidence lines,
|
|
117
|
+
aborts with exit code `1` naming the first failed check, and exits `0`
|
|
118
|
+
only through the all-checks-passed summary path.
|
|
119
|
+
@return {None} Assertions only.
|
|
120
|
+
@satisfies TST-015, REQ-079, REQ-080
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
content = _read_script()
|
|
124
|
+
assert "PASS: " in content
|
|
125
|
+
assert "FAIL: " in content
|
|
126
|
+
assert "exit 1" in content
|
|
127
|
+
assert "exit 0" in content
|
|
128
|
+
assert "Pre-release check FAILED at check: " in content
|
|
129
|
+
assert "Pre-release check PASSED" in content
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_pre_release_script_cleans_generated_artifacts():
|
|
133
|
+
"""
|
|
134
|
+
@brief Validate generated-artifact cleanup registration.
|
|
135
|
+
@details Asserts the script defines `cleanup_artifacts`, registers it as
|
|
136
|
+
an EXIT trap so success and failure paths both clean up, removes
|
|
137
|
+
`dist`, `build`, `.pytest_cache`, `src/shellscripts.egg-info`, and
|
|
138
|
+
`__pycache__` directories under `src` and `tests`, and prints a
|
|
139
|
+
`clean` evidence line listing the removed artifact groups.
|
|
140
|
+
@return {None} Assertions only.
|
|
141
|
+
@satisfies TST-015, DES-026, DES-027, REQ-081, REQ-082
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
content = _read_script()
|
|
145
|
+
assert "cleanup_artifacts" in content
|
|
146
|
+
assert "trap cleanup_artifacts EXIT" in content
|
|
147
|
+
assert "rm -rf -- dist build .pytest_cache src/shellscripts.egg-info" in content
|
|
148
|
+
assert "find src tests -type d -name __pycache__" in content
|
|
149
|
+
assert "clean: dist build .pytest_cache src/shellscripts.egg-info __pycache__" in content
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@brief Validate release workflow pre-release verification gating contract.
|
|
3
|
+
@details Parses the repository workflow artifact
|
|
4
|
+
`.github/workflows/release-pypi.yml` as static text and verifies that a
|
|
5
|
+
dedicated `pre-release-check` job executes `scripts/pre-release-check.sh`
|
|
6
|
+
before release artifact creation and that the `build-release` job declares
|
|
7
|
+
`needs` on it, so any verification failure prevents artifact creation,
|
|
8
|
+
attestation, changelog, GitHub release, and PyPI publication (DES-028,
|
|
9
|
+
DES-029, REQ-083).
|
|
10
|
+
Level classification: all helpers are Level 0 (pure text parsing over a
|
|
11
|
+
tracked static artifact); test functions are Level 1 consumers with no
|
|
12
|
+
cross-test dependencies and no network, filesystem side effects, clock,
|
|
13
|
+
or randomness exposure.
|
|
14
|
+
@satisfies TST-016, DES-028, DES-029, REQ-083
|
|
15
|
+
@return {None} Pytest module scope.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# @var WORKFLOW_PATH {Path} Absolute path of the release workflow artifact under verification; resolved from this test file location to remain working-directory independent.
|
|
21
|
+
WORKFLOW_PATH = (
|
|
22
|
+
Path(__file__).resolve().parents[1] / ".github" / "workflows" / "release-pypi.yml"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# @var PRECHECK_JOB_HEADER {str} Exact two-space-indented job header declaring the dedicated pre-release verification job (DES-028).
|
|
26
|
+
PRECHECK_JOB_HEADER = " pre-release-check:"
|
|
27
|
+
|
|
28
|
+
# @var PRECHECK_SCRIPT_COMMAND {str} Runner line that must execute the mandatory pre-release pipeline inside the dedicated job (DES-028).
|
|
29
|
+
PRECHECK_SCRIPT_COMMAND = "run: bash scripts/pre-release-check.sh"
|
|
30
|
+
|
|
31
|
+
# @var PRECHECK_SYNC_COMMAND {str} Runner line that provisions the project virtual environment with dev dependencies before script execution (DES-028).
|
|
32
|
+
PRECHECK_SYNC_COMMAND = "run: uv sync --frozen"
|
|
33
|
+
|
|
34
|
+
# @var BUILD_RELEASE_NEEDS_LINE {str} Exact `needs` declaration that gates the release build job on the pre-release verification job (DES-029, REQ-083).
|
|
35
|
+
BUILD_RELEASE_NEEDS_LINE = "needs: [check-branch, pre-release-check]"
|
|
36
|
+
|
|
37
|
+
# @var FORBIDDEN_PUBLISH_ACTIONS {tuple[str, ...]} Release/publication action slugs that MUST NOT appear inside the pre-release verification job.
|
|
38
|
+
FORBIDDEN_PUBLISH_ACTIONS = (
|
|
39
|
+
"pypa/gh-action-pypi-publish",
|
|
40
|
+
"softprops/action-gh-release",
|
|
41
|
+
"actions/attest-build-provenance",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_workflow_text() -> str:
|
|
46
|
+
"""
|
|
47
|
+
@brief Read the release workflow artifact as decoded UTF-8 text.
|
|
48
|
+
@details Performs a single read of the tracked workflow file referenced by
|
|
49
|
+
`WORKFLOW_PATH`; no writes, network, or environment access. Time
|
|
50
|
+
complexity O(n) where n is the file byte size.
|
|
51
|
+
@pre Workflow artifact exists at `WORKFLOW_PATH`.
|
|
52
|
+
@return {str} Full workflow file content.
|
|
53
|
+
@throws {OSError} When the workflow artifact is missing or unreadable.
|
|
54
|
+
@satisfies TST-016
|
|
55
|
+
"""
|
|
56
|
+
return WORKFLOW_PATH.read_text(encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_job_block(workflow_text: str, job_name: str) -> str:
|
|
60
|
+
"""
|
|
61
|
+
@brief Extract one job definition block from workflow text.
|
|
62
|
+
@details Slices from the two-space-indented `job_name:` header until the
|
|
63
|
+
next two-space-indented job header or document end. Time complexity
|
|
64
|
+
O(n) over payload length.
|
|
65
|
+
@param workflow_text {str} Full workflow file content.
|
|
66
|
+
@param job_name {str} Job key to extract.
|
|
67
|
+
@return {str} Job block text.
|
|
68
|
+
@throws {AssertionError} When the job key is absent.
|
|
69
|
+
@satisfies TST-016
|
|
70
|
+
"""
|
|
71
|
+
lines = workflow_text.splitlines()
|
|
72
|
+
start_index = None
|
|
73
|
+
for index, line in enumerate(lines):
|
|
74
|
+
if line == f" {job_name}:":
|
|
75
|
+
start_index = index
|
|
76
|
+
break
|
|
77
|
+
assert start_index is not None, f"missing job definition: {job_name}"
|
|
78
|
+
|
|
79
|
+
end_index = len(lines)
|
|
80
|
+
for index in range(start_index + 1, len(lines)):
|
|
81
|
+
line = lines[index]
|
|
82
|
+
if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"):
|
|
83
|
+
end_index = index
|
|
84
|
+
break
|
|
85
|
+
return "\n".join(lines[start_index:end_index])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_release_workflow_declares_pre_release_check_job():
|
|
89
|
+
"""
|
|
90
|
+
@brief Verify the dedicated pre-release verification job exists and runs
|
|
91
|
+
the mandatory pipeline before artifact creation.
|
|
92
|
+
@details Asserts the `pre-release-check` job is declared before the
|
|
93
|
+
`build-release` job, provisions dependencies with `uv sync --frozen`,
|
|
94
|
+
and executes `scripts/pre-release-check.sh` as a runner step.
|
|
95
|
+
@return {None} Assertions only.
|
|
96
|
+
@satisfies TST-016, DES-028
|
|
97
|
+
"""
|
|
98
|
+
workflow_text = _read_workflow_text()
|
|
99
|
+
|
|
100
|
+
assert PRECHECK_JOB_HEADER in workflow_text, (
|
|
101
|
+
"dedicated pre-release-check job missing from release workflow"
|
|
102
|
+
)
|
|
103
|
+
assert workflow_text.index(PRECHECK_JOB_HEADER) < workflow_text.index(
|
|
104
|
+
" build-release:"
|
|
105
|
+
), "pre-release-check job must be declared before build-release"
|
|
106
|
+
|
|
107
|
+
precheck_block = _extract_job_block(workflow_text, "pre-release-check")
|
|
108
|
+
assert PRECHECK_SYNC_COMMAND in precheck_block, (
|
|
109
|
+
"pre-release-check job must provision the virtual environment with uv sync"
|
|
110
|
+
)
|
|
111
|
+
assert PRECHECK_SCRIPT_COMMAND in precheck_block, (
|
|
112
|
+
"pre-release-check job must execute scripts/pre-release-check.sh"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_release_workflow_build_release_needs_pre_release_check():
|
|
117
|
+
"""
|
|
118
|
+
@brief Verify the build-release job declares needs on the pre-release job.
|
|
119
|
+
@details Asserts the exact `needs` list on `build-release` contains both
|
|
120
|
+
`check-branch` and `pre-release-check`, which is the GitHub Actions
|
|
121
|
+
mechanism that skips every artifact-creation and publication step when
|
|
122
|
+
verification fails.
|
|
123
|
+
@return {None} Assertions only.
|
|
124
|
+
@satisfies TST-016, DES-029, REQ-083
|
|
125
|
+
"""
|
|
126
|
+
workflow_text = _read_workflow_text()
|
|
127
|
+
build_release_block = _extract_job_block(workflow_text, "build-release")
|
|
128
|
+
|
|
129
|
+
assert BUILD_RELEASE_NEEDS_LINE in build_release_block, (
|
|
130
|
+
"build-release job must gate on [check-branch, pre-release-check]"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_pre_release_job_does_not_publish_or_create_release_assets():
|
|
135
|
+
"""
|
|
136
|
+
@brief Verify the pre-release job stays verification-only.
|
|
137
|
+
@details Negative/regression guard: the dedicated verification job must
|
|
138
|
+
not contain PyPI publication, GitHub release, or provenance attestation
|
|
139
|
+
actions, keeping all release-side effects exclusively inside the gated
|
|
140
|
+
`build-release` job.
|
|
141
|
+
@return {None} Assertions only.
|
|
142
|
+
@satisfies TST-016, REQ-083
|
|
143
|
+
"""
|
|
144
|
+
workflow_text = _read_workflow_text()
|
|
145
|
+
precheck_block = _extract_job_block(workflow_text, "pre-release-check")
|
|
146
|
+
|
|
147
|
+
for forbidden_action in FORBIDDEN_PUBLISH_ACTIONS:
|
|
148
|
+
assert forbidden_action not in precheck_block, (
|
|
149
|
+
f"verification job must not perform release-side effects: "
|
|
150
|
+
f"{forbidden_action}"
|
|
151
|
+
)
|
|
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
|
|
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
|
|
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
|