shellscripts 0.23.1__tar.gz → 0.24.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 (63) hide show
  1. {shellscripts-0.23.1 → shellscripts-0.24.0}/PKG-INFO +2 -2
  2. {shellscripts-0.23.1 → shellscripts-0.24.0}/README.md +1 -1
  3. {shellscripts-0.23.1 → shellscripts-0.24.0}/pyproject.toml +1 -1
  4. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/__init__.py +1 -1
  5. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_split_by_format.py +44 -1
  6. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_split_by_toc.py +44 -1
  7. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shellscripts.egg-info/PKG-INFO +2 -2
  8. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shellscripts.egg-info/SOURCES.txt +3 -1
  9. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_007_pdf_commands.py +156 -0
  10. shellscripts-0.24.0/tests/test_tst_015_pre_release_check.py +149 -0
  11. shellscripts-0.24.0/tests/test_tst_016_release_precheck_gating.py +151 -0
  12. {shellscripts-0.23.1 → shellscripts-0.24.0}/LICENSE +0 -0
  13. {shellscripts-0.23.1 → shellscripts-0.24.0}/setup.cfg +0 -0
  14. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/__main__.py +0 -0
  15. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/__init__.py +0 -0
  16. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/_dc_common.py +0 -0
  17. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/ai_install.py +0 -0
  18. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/bin_links.py +0 -0
  19. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/claude.py +0 -0
  20. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/clean.py +0 -0
  21. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/codex.py +0 -0
  22. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/copilot.py +0 -0
  23. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/dicom2jpg.py +0 -0
  24. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/dicomviewer.py +0 -0
  25. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/diff_cmd.py +0 -0
  26. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/doxygen_cmd.py +0 -0
  27. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/edit_cmd.py +0 -0
  28. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/gemini.py +0 -0
  29. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/kiro.py +0 -0
  30. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/opencode.py +0 -0
  31. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_crop.py +0 -0
  32. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_merge.py +0 -0
  33. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_tiler_090.py +0 -0
  34. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_tiler_100.py +0 -0
  35. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pdf_toc_clean.py +0 -0
  36. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/pi.py +0 -0
  37. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/req_cmd.py +0 -0
  38. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/tests_cmd.py +0 -0
  39. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/ubuntu_dark_theme.py +0 -0
  40. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/venv_cmd.py +0 -0
  41. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/video2h264.py +0 -0
  42. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/video2h265.py +0 -0
  43. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/view_cmd.py +0 -0
  44. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/vscode_cmd.py +0 -0
  45. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/commands/vsinsider_cmd.py +0 -0
  46. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/config.py +0 -0
  47. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/core.py +0 -0
  48. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/utils.py +0 -0
  49. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shell_scripts/version_check.py +0 -0
  50. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shellscripts.egg-info/dependency_links.txt +0 -0
  51. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shellscripts.egg-info/entry_points.txt +0 -0
  52. {shellscripts-0.23.1 → shellscripts-0.24.0}/src/shellscripts.egg-info/top_level.txt +0 -0
  53. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_001_002_core.py +0 -0
  54. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_003_ai_install.py +0 -0
  55. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_004_bin_links_clean.py +0 -0
  56. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_005_cli_launchers.py +0 -0
  57. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_006_dc_common.py +0 -0
  58. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_008_env_commands.py +0 -0
  59. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_010_req_cmd.py +0 -0
  60. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_011_video_commands.py +0 -0
  61. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_012_workflow_runtime.py +0 -0
  62. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_013_release_pypi_gating.py +0 -0
  63. {shellscripts-0.23.1 → shellscripts-0.24.0}/tests/test_tst_014_pypi_version_source.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shellscripts
3
- Version: 0.23.1
3
+ Version: 0.24.0
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.23.1)
34
+ # s/shellScripts (0.24.0)
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.23.1)
13
+ # s/shellScripts (0.24.0)
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+">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shellscripts"
7
- version = "0.23.1"
7
+ version = "0.24.0"
8
8
  description = "Execute useful shell scripts via an uvx-compatible CLI interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- __version__ = "0.23.1"
1
+ __version__ = "0.24.0"
2
2
 
3
3
  from .core import main # noqa: F401
4
4
 
@@ -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
- os.replace(with_toc, output_file)
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
- os.replace(with_toc, output_file)
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shellscripts
3
- Version: 0.23.1
3
+ Version: 0.24.0
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.23.1)
34
+ # s/shellScripts (0.24.0)
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.
@@ -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