shellscripts 0.23.1__py3-none-any.whl

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 (45) hide show
  1. shell_scripts/__init__.py +5 -0
  2. shell_scripts/__main__.py +5 -0
  3. shell_scripts/commands/__init__.py +81 -0
  4. shell_scripts/commands/_dc_common.py +138 -0
  5. shell_scripts/commands/ai_install.py +453 -0
  6. shell_scripts/commands/bin_links.py +78 -0
  7. shell_scripts/commands/claude.py +56 -0
  8. shell_scripts/commands/clean.py +98 -0
  9. shell_scripts/commands/codex.py +107 -0
  10. shell_scripts/commands/copilot.py +54 -0
  11. shell_scripts/commands/dicom2jpg.py +98 -0
  12. shell_scripts/commands/dicomviewer.py +83 -0
  13. shell_scripts/commands/diff_cmd.py +50 -0
  14. shell_scripts/commands/doxygen_cmd.py +159 -0
  15. shell_scripts/commands/edit_cmd.py +50 -0
  16. shell_scripts/commands/gemini.py +54 -0
  17. shell_scripts/commands/kiro.py +54 -0
  18. shell_scripts/commands/opencode.py +54 -0
  19. shell_scripts/commands/pdf_crop.py +520 -0
  20. shell_scripts/commands/pdf_merge.py +180 -0
  21. shell_scripts/commands/pdf_split_by_format.py +243 -0
  22. shell_scripts/commands/pdf_split_by_toc.py +197 -0
  23. shell_scripts/commands/pdf_tiler_090.py +61 -0
  24. shell_scripts/commands/pdf_tiler_100.py +61 -0
  25. shell_scripts/commands/pdf_toc_clean.py +175 -0
  26. shell_scripts/commands/pi.py +61 -0
  27. shell_scripts/commands/req_cmd.py +362 -0
  28. shell_scripts/commands/tests_cmd.py +87 -0
  29. shell_scripts/commands/ubuntu_dark_theme.py +64 -0
  30. shell_scripts/commands/venv_cmd.py +82 -0
  31. shell_scripts/commands/video2h264.py +89 -0
  32. shell_scripts/commands/video2h265.py +87 -0
  33. shell_scripts/commands/view_cmd.py +50 -0
  34. shell_scripts/commands/vscode_cmd.py +42 -0
  35. shell_scripts/commands/vsinsider_cmd.py +42 -0
  36. shell_scripts/config.py +328 -0
  37. shell_scripts/core.py +324 -0
  38. shell_scripts/utils.py +455 -0
  39. shell_scripts/version_check.py +323 -0
  40. shellscripts-0.23.1.dist-info/METADATA +310 -0
  41. shellscripts-0.23.1.dist-info/RECORD +45 -0
  42. shellscripts-0.23.1.dist-info/WHEEL +5 -0
  43. shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
  44. shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
  45. shellscripts-0.23.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from shell_scripts.utils import require_commands, print_error
7
+
8
+ PROGRAM = "shellscripts"
9
+ DESCRIPTION = "Remove out-of-range TOC entries from PDF files."
10
+
11
+
12
+ def print_help(version):
13
+ print(f"Usage: {PROGRAM} pdf-toc-clean <file1.pdf> [file2.pdf ...] ({version})")
14
+ print()
15
+ print("pdf-toc-clean options:")
16
+ print(" <files...> - Input PDF files to clean.")
17
+ print(" --help - Show this help message.")
18
+ print()
19
+ print("For each input PDF, writes <basename>_toc-clean.pdf with")
20
+ print("out-of-range TOC entries removed.")
21
+
22
+
23
+ def _filter_bookmarks(dump_content, max_pages):
24
+ result = []
25
+ level = title = page = ""
26
+ for line in dump_content.splitlines():
27
+ if line == "BookmarkBegin":
28
+ if (level.isdigit() and page.isdigit()
29
+ and 1 <= int(page) <= max_pages):
30
+ result.append("BookmarkBegin")
31
+ result.append(f"BookmarkTitle: {title}")
32
+ result.append(f"BookmarkLevel: {level}")
33
+ result.append(f"BookmarkPageNumber: {page}")
34
+ level = title = page = ""
35
+ elif line.startswith("BookmarkTitle: "):
36
+ title = line[15:]
37
+ elif line.startswith("BookmarkLevel: "):
38
+ level = line.split()[1]
39
+ elif line.startswith("BookmarkPageNumber: "):
40
+ page = line.split()[1]
41
+ if level.isdigit() and page.isdigit() and 1 <= int(page) <= max_pages:
42
+ result.append("BookmarkBegin")
43
+ result.append(f"BookmarkTitle: {title}")
44
+ result.append(f"BookmarkLevel: {level}")
45
+ result.append(f"BookmarkPageNumber: {page}")
46
+ return "\n".join(result)
47
+
48
+
49
+ def _get_num_pages(dump_content):
50
+ for line in dump_content.splitlines():
51
+ if line.startswith("NumberOfPages:"):
52
+ return int(line.split()[1])
53
+ return None
54
+
55
+
56
+ def _has_out_of_range(dump_content, max_pages):
57
+ page = ""
58
+ for line in dump_content.splitlines():
59
+ if line == "BookmarkBegin":
60
+ if page and page.isdigit():
61
+ p = int(page)
62
+ if p < 1 or p > max_pages:
63
+ return True
64
+ page = ""
65
+ elif line.startswith("BookmarkPageNumber: "):
66
+ page = line.split()[1]
67
+ if page and page.isdigit():
68
+ p = int(page)
69
+ if p < 1 or p > max_pages:
70
+ return True
71
+ return False
72
+
73
+
74
+ def _clean_one(input_pdf):
75
+ if not os.path.isfile(input_pdf):
76
+ print(f"Warning: input file '{input_pdf}' does not exist; skipping.", flush=True)
77
+ return 0
78
+
79
+ dir_name = os.path.dirname(input_pdf) or "."
80
+ base_name = os.path.splitext(os.path.basename(input_pdf))[0]
81
+ output_pdf = os.path.join(dir_name, f"{base_name}_toc-clean.pdf")
82
+
83
+ with tempfile.TemporaryDirectory() as tmp_dir:
84
+ tmp_pdf = os.path.join(tmp_dir, "prepared.pdf")
85
+ stripped_pdf = os.path.join(tmp_dir, "stripped.pdf")
86
+ dump_file = os.path.join(tmp_dir, "dump.txt")
87
+ bookmarks_file = os.path.join(tmp_dir, "bookmarks.info")
88
+ cleaned_pdf = os.path.join(tmp_dir, "cleaned.pdf")
89
+
90
+ subprocess.run(
91
+ ["qpdf", "--object-streams=disable", input_pdf, tmp_pdf],
92
+ capture_output=True,
93
+ )
94
+
95
+ r = subprocess.run(
96
+ ["pdftk", tmp_pdf, "dump_data_utf8"],
97
+ capture_output=True, text=True,
98
+ )
99
+ if r.returncode != 0:
100
+ r = subprocess.run(
101
+ ["pdftk", tmp_pdf, "dump_data"],
102
+ capture_output=True, text=True,
103
+ )
104
+ dump_content = r.stdout
105
+
106
+ with open(dump_file, "w") as f:
107
+ f.write(dump_content)
108
+
109
+ total_pages = _get_num_pages(dump_content)
110
+ if total_pages is None:
111
+ print_error(f"Unable to read NumberOfPages for '{input_pdf}'.")
112
+ return 1
113
+
114
+ subprocess.run(
115
+ ["qpdf", "--empty", "--pages", tmp_pdf, "1-z", "--", stripped_pdf],
116
+ capture_output=True,
117
+ )
118
+
119
+ filtered = _filter_bookmarks(dump_content, total_pages)
120
+ with open(bookmarks_file, "w") as f:
121
+ f.write(filtered)
122
+
123
+ r = subprocess.run(
124
+ ["pdftk", stripped_pdf, "update_info_utf8", bookmarks_file, "output", cleaned_pdf],
125
+ capture_output=True,
126
+ )
127
+ utf8_ok = r.returncode == 0
128
+
129
+ if utf8_ok:
130
+ verify_r = subprocess.run(
131
+ ["pdftk", cleaned_pdf, "dump_data_utf8"],
132
+ capture_output=True, text=True,
133
+ )
134
+ if verify_r.returncode != 0:
135
+ verify_r = subprocess.run(
136
+ ["pdftk", cleaned_pdf, "dump_data"],
137
+ capture_output=True, text=True,
138
+ )
139
+ if _has_out_of_range(verify_r.stdout, total_pages):
140
+ r = subprocess.run(
141
+ ["pdftk", stripped_pdf, "update_info", bookmarks_file, "output", cleaned_pdf],
142
+ capture_output=True,
143
+ )
144
+ if r.returncode != 0:
145
+ print_error(f"Unable to apply cleaned TOC for '{input_pdf}'.")
146
+ return 1
147
+ elif not utf8_ok:
148
+ r = subprocess.run(
149
+ ["pdftk", stripped_pdf, "update_info", bookmarks_file, "output", cleaned_pdf],
150
+ capture_output=True,
151
+ )
152
+ if r.returncode != 0:
153
+ print_error(f"Unable to apply cleaned TOC for '{input_pdf}'.")
154
+ return 1
155
+
156
+ subprocess.run(["qpdf", "--linearize", cleaned_pdf, output_pdf])
157
+ print(f"Cleaned TOC: {input_pdf} -> {output_pdf}")
158
+
159
+ return 0
160
+
161
+
162
+ def run(args):
163
+ if not args:
164
+ print_error("No PDF files specified.")
165
+ print_help("")
166
+ return 1
167
+
168
+ require_commands("pdftk", "qpdf")
169
+
170
+ overall = 0
171
+ for pdf in args:
172
+ if _clean_one(pdf) != 0:
173
+ overall = 1
174
+
175
+ return overall
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """@brief pi.dev CLI launcher in project context.
3
+
4
+ @details Ensures project-root prerequisite and executable availability before
5
+ blocking subprocess invocation of `pi`. Forwards every CLI argument unchanged
6
+ and never injects implicit `--tools` parameters. Time complexity O(n), where
7
+ n is forwarded argument count.
8
+ @satisfies REQ-055, REQ-056, REQ-064, REQ-068, REQ-069
9
+ """
10
+
11
+ import subprocess
12
+
13
+ from shell_scripts.utils import require_project_root, require_commands
14
+
15
+ ## @var PROGRAM
16
+ # @brief Base CLI program name used in help output.
17
+ # @details Constant identifier for usage-line rendering in command help.
18
+ PROGRAM = "shellscripts"
19
+
20
+ ## @var DESCRIPTION
21
+ # @brief One-line command description for dispatcher help surfaces.
22
+ # @details Exposed by command registry introspection (`get_all_commands`).
23
+ DESCRIPTION = "Launch pi.dev CLI in the project context."
24
+
25
+
26
+ def print_help(version: str) -> None:
27
+ """@brief Print command-specific help for `pi`.
28
+
29
+ @details Emits usage and pass-through argument behavior for deterministic
30
+ terminal rendering; documents that every CLI argument is forwarded to the
31
+ pi.dev CLI without mutation or implicit option injection. Time complexity
32
+ O(1).
33
+ @param version {str} CLI version string propagated by dispatcher.
34
+ @return {None} Writes help text to stdout.
35
+ @satisfies DES-008, REQ-068, REQ-069
36
+ """
37
+
38
+ print(f"Usage: {PROGRAM} pi [args...] ({version})")
39
+ print()
40
+ print("pi options:")
41
+ print(" All arguments are passed through to the pi.dev CLI unchanged.")
42
+ print(" No implicit --tools argument is added by shellscripts.")
43
+ print(" --help - Show this help message.")
44
+
45
+
46
+ def run(args: list[str]) -> int:
47
+ """@brief Launch pi.dev CLI after external executable validation.
48
+
49
+ @details Resolves project root, checks executable availability for `pi`,
50
+ and forwards all CLI arguments unchanged to the child process. The command
51
+ vector shape is `[resolved_pi_exec] + args`. Time complexity O(n), where n
52
+ is forwarded argument count.
53
+ @param args {list[str]} Additional CLI args forwarded to pi.dev.
54
+ @return {int} Child process return code.
55
+ @satisfies REQ-055, REQ-056, REQ-064, REQ-068, REQ-069
56
+ """
57
+
58
+ require_project_root()
59
+ resolved_exec = require_commands("pi")
60
+ result = subprocess.run([resolved_exec, *args])
61
+ return result.returncode
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """@brief `req` bootstrap orchestrator for current/child project directories.
3
+
4
+ @details Applies cleanup and scaffold actions, emits one cleanup evidence line
5
+ for every predefined cleanup path, then executes external `req` installation
6
+ arguments for selected target directories. Target selection mode is current
7
+ directory by default, first-level child directories with `--dirs`, or all
8
+ descendant directories with `--recursive`. Provider/static-check argument lists
9
+ are resolved from runtime config with hardcoded fallback defaults.
10
+ @satisfies PRJ-003, DES-008, REQ-048, REQ-049, REQ-050, REQ-051, REQ-052,
11
+ REQ-053, REQ-054, REQ-056, REQ-062, REQ-063, REQ-070, REQ-071
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ from shell_scripts.config import get_req_profile
21
+ from shell_scripts.utils import print_error, require_commands
22
+
23
+ PROGRAM = "shellscripts"
24
+ DESCRIPTION = "Run useReq bootstrap on current or discovered directories."
25
+
26
+ CLEANUP_PATHS: tuple[str, ...] = (
27
+ ".gemini/commands",
28
+ ".gemini/skills",
29
+ ".claude/commands",
30
+ ".claude/agents",
31
+ ".claude/skills",
32
+ ".github/prompts",
33
+ ".github/agents",
34
+ ".github/skills",
35
+ ".codex/prompts",
36
+ ".codex/skills",
37
+ ".kiro/prompts",
38
+ ".kiro/agents",
39
+ ".kiro/skills",
40
+ ".opencode/prompt",
41
+ ".opencode/command",
42
+ ".opencode/skill",
43
+ ".pi/prompts",
44
+ ".pi/skills",
45
+ )
46
+
47
+ REQUIRED_DIRS: tuple[str, ...] = (
48
+ "guidelines",
49
+ "docs",
50
+ "tests",
51
+ "src",
52
+ "scripts",
53
+ ".github/workflows",
54
+ )
55
+
56
+ CleanupEvidence = tuple[str, str, Path]
57
+
58
+
59
+ def _is_hidden_path(path: Path, base_dir: Path) -> bool:
60
+ """@brief Determine whether path contains hidden segments below base.
61
+
62
+ @details Computes relative parts from `base_dir` and returns `True` when any
63
+ path segment starts with a dot-prefix, preventing accidental traversal of
64
+ hidden metadata directories (for example `.git`).
65
+ @param path {Path} Candidate directory path.
66
+ @param base_dir {Path} Root directory used for relative-segment evaluation.
67
+ @return {bool} `True` when candidate has hidden relative segments.
68
+ @satisfies REQ-052, REQ-053
69
+ """
70
+
71
+ return any(part.startswith(".") for part in path.relative_to(base_dir).parts)
72
+
73
+
74
+ def print_help(version: str) -> None:
75
+ """@brief Render command help for `req`.
76
+
77
+ @details Prints selector options and behavior contract for target directory
78
+ discovery and external `req` invocation flow.
79
+ @param version {str} CLI version string appended in usage output.
80
+ @return {None} Writes help text to stdout.
81
+ @satisfies DES-008
82
+ """
83
+
84
+ print(f"Usage: {PROGRAM} req [--dirs | --recursive] ({version})")
85
+ print()
86
+ print("req options:")
87
+ print(" --dirs - Target first-level child directories only.")
88
+ print(" --recursive - Target all descendant directories recursively.")
89
+ print(" --help - Show this help message.")
90
+
91
+
92
+ def _iter_first_level_dirs(base_dir: Path) -> list[Path]:
93
+ """@brief Collect first-level child directories in deterministic order.
94
+
95
+ @details Enumerates direct children of `base_dir`, keeps only directories,
96
+ and sorts by path string for stable command behavior.
97
+ @param base_dir {Path} Directory whose first-level children are listed.
98
+ @return {list[Path]} Sorted first-level child directories.
99
+ @satisfies REQ-052
100
+ """
101
+
102
+ return sorted(
103
+ (
104
+ entry
105
+ for entry in base_dir.iterdir()
106
+ if entry.is_dir() and not _is_hidden_path(entry, base_dir)
107
+ ),
108
+ key=str,
109
+ )
110
+
111
+
112
+ def _iter_descendant_dirs(base_dir: Path) -> list[Path]:
113
+ """@brief Collect descendant directories recursively in deterministic order.
114
+
115
+ @details Traverses all descendants via glob expansion, excludes `base_dir`
116
+ itself, keeps only directories, and sorts by path string.
117
+ @param base_dir {Path} Directory whose descendants are listed.
118
+ @return {list[Path]} Sorted descendant directory list excluding `base_dir`.
119
+ @satisfies REQ-053
120
+ """
121
+
122
+ return sorted(
123
+ (
124
+ entry
125
+ for entry in base_dir.rglob("*")
126
+ if entry.is_dir() and not _is_hidden_path(entry, base_dir)
127
+ ),
128
+ key=str,
129
+ )
130
+
131
+
132
+ def _build_req_args(target_dir: Path) -> list[str]:
133
+ """@brief Build external `req` argument vector for one target directory.
134
+
135
+ @details Uses hardcoded non-overridable arguments and appends repeated
136
+ runtime-configured providers/static-check entries sourced from `get_req_profile`.
137
+ @param target_dir {Path} Target directory used to parameterize path flags.
138
+ @return {list[str]} External `req` argv vector.
139
+ @satisfies REQ-049, REQ-050
140
+ """
141
+
142
+ providers, static_checks = get_req_profile()
143
+ args = [
144
+ "req",
145
+ "--base",
146
+ str(target_dir),
147
+ "--docs-dir",
148
+ str(target_dir / "docs"),
149
+ "--guidelines-dir",
150
+ str(target_dir / "guidelines"),
151
+ "--src-dir",
152
+ str(target_dir / "src"),
153
+ "--src-dir",
154
+ str(target_dir / "scripts"),
155
+ "--src-dir",
156
+ str(target_dir / ".github/workflows"),
157
+ "--tests-dir",
158
+ str(target_dir / "tests"),
159
+ ]
160
+ for provider in providers:
161
+ args.extend(["--provider", provider])
162
+ args.append("--upgrade-guidelines")
163
+ for static_check in static_checks:
164
+ args.extend(["--enable-static-check", static_check])
165
+ return args
166
+
167
+
168
+ def _delete_cleanup_path(cleanup_path: Path) -> tuple[str, str]:
169
+ """@brief Remove one predefined cleanup path when it exists.
170
+
171
+ @details Evaluates one cleanup candidate path, returns `skip` when the path
172
+ is absent, removes directories with `shutil.rmtree`, removes non-directory
173
+ filesystem entries with `Path.unlink`, and classifies deleted entries as
174
+ `dir` or `file`. Time complexity is O(n) for directory trees and O(1) for
175
+ non-directory entries.
176
+ @param cleanup_path {Path} Absolute candidate cleanup path for one target.
177
+ @return {tuple[str, str]} Status-kind pair shaped as (`deleted`, `dir`),
178
+ (`deleted`, `file`), or (`skip`, `missing`).
179
+ @satisfies REQ-048, REQ-062, REQ-063
180
+ """
181
+
182
+ if not cleanup_path.exists() and not cleanup_path.is_symlink():
183
+ return ("skip", "missing")
184
+ if cleanup_path.is_dir() and not cleanup_path.is_symlink():
185
+ shutil.rmtree(cleanup_path)
186
+ return ("deleted", "dir")
187
+ cleanup_path.unlink()
188
+ return ("deleted", "file")
189
+
190
+
191
+ def _print_cleanup_evidence(evidence: CleanupEvidence) -> None:
192
+ """@brief Emit one cleanup evidence line in deterministic token order.
193
+
194
+ @details Prints a parser-friendly line using fixed `clean | <status> |
195
+ <kind> | <path>` tokens so downstream checks can differentiate deleted
196
+ files, deleted directories, and skipped missing paths without reading
197
+ surrounding prose. Time complexity is O(1).
198
+ @param evidence {CleanupEvidence} Tuple `(status, kind, path)` produced by
199
+ cleanup preparation logic.
200
+ @return {None} Writes one stdout line.
201
+ @satisfies REQ-062, REQ-063
202
+ """
203
+
204
+ status, kind, path = evidence
205
+ print(f"clean | {status} | {kind} | {path}")
206
+
207
+
208
+ def _prepare_target_directory(target_dir: Path) -> list[CleanupEvidence]:
209
+ """@brief Apply cleanup and scaffold operations for one target directory.
210
+
211
+ @details Evaluates every predefined cleanup path, records deterministic
212
+ cleanup evidence tuples, removes existing filesystem entries, and ensures
213
+ required project subdirectories exist before external `req` call. Time
214
+ complexity is O(m + d) where `m` is cleanup-path count and `d` is total
215
+ removed directory-tree entries.
216
+ @param target_dir {Path} Target directory to mutate.
217
+ @return {list[CleanupEvidence]} Cleanup evidence entries in configured path
218
+ order.
219
+ @satisfies REQ-048, REQ-062, REQ-063
220
+ """
221
+
222
+ evidence: list[CleanupEvidence] = []
223
+ for rel_path in CLEANUP_PATHS:
224
+ cleanup_path = target_dir / rel_path
225
+ status, kind = _delete_cleanup_path(cleanup_path)
226
+ evidence.append((status, kind, cleanup_path))
227
+ for rel_path in REQUIRED_DIRS:
228
+ (target_dir / rel_path).mkdir(parents=True, exist_ok=True)
229
+ return evidence
230
+
231
+
232
+ def _is_git_repository_root(target_dir: Path) -> bool:
233
+ """@brief Check whether target directory is a Git repository root.
234
+
235
+ @details Executes `git -C <target> rev-parse --show-toplevel`, returns
236
+ `False` on command failure, and compares normalized absolute paths to ensure
237
+ the target directory matches the repository root exactly. Time complexity is
238
+ O(1) excluding external process startup overhead.
239
+ @param target_dir {Path} Candidate target directory.
240
+ @return {bool} `True` when target directory is Git root; otherwise `False`.
241
+ @satisfies REQ-070, REQ-071
242
+ """
243
+
244
+ result = subprocess.run(
245
+ ["git", "-C", str(target_dir), "rev-parse", "--show-toplevel"],
246
+ capture_output=True,
247
+ text=True,
248
+ check=False,
249
+ )
250
+ if result.returncode != 0:
251
+ return False
252
+ repo_root = result.stdout.strip()
253
+ if not repo_root:
254
+ return False
255
+ return Path(repo_root).resolve() == target_dir.resolve()
256
+
257
+
258
+ def _print_install_skipped(target_dir: Path) -> None:
259
+ """@brief Emit skip evidence when target directory is not Git root.
260
+
261
+ @details Prints one parser-stable line containing `skip` and token
262
+ `skippata` to document installation omission for non-root directories in
263
+ current-directory and `--dirs` modes. Time complexity is O(1).
264
+ @param target_dir {Path} Directory skipped from cleanup and installation.
265
+ @return {None} Writes one stdout line.
266
+ @satisfies REQ-070, REQ-071
267
+ """
268
+
269
+ print(
270
+ "install | skip | "
271
+ f"{target_dir} | installation skippata: target is not a git root"
272
+ )
273
+
274
+
275
+ def run(args: list[str]) -> int:
276
+ """@brief Execute `req` orchestration for selected directory targets.
277
+
278
+ @details Parses mutually exclusive selector options, resolves target set,
279
+ applies cleanup/scaffold phase with per-path evidence emission, and executes
280
+ external `req` for each target. Returns `1` on invalid option combinations
281
+ or unknown options. Converts external `req` non-zero exits into explicit
282
+ error output and propagated return codes.
283
+ @param args {list[str]} Command arguments excluding `req` token.
284
+ @return {int} `0` on success; non-zero for option or subprocess failures.
285
+ @exception {subprocess.CalledProcessError} Internally handled and converted
286
+ to deterministic return code + error output.
287
+ @satisfies REQ-048, REQ-049, REQ-051, REQ-052, REQ-053, REQ-054, REQ-056,
288
+ REQ-062, REQ-063, REQ-070, REQ-071
289
+ """
290
+
291
+ mode_current = True
292
+ mode_dirs = False
293
+ mode_recursive = False
294
+ require_commands("req")
295
+
296
+ for arg in args:
297
+ if arg == "--dirs":
298
+ mode_dirs = True
299
+ mode_current = False
300
+ elif arg == "--recursive":
301
+ mode_recursive = True
302
+ mode_current = False
303
+ else:
304
+ print_error(f"Unknown option: {arg}")
305
+ return 1
306
+
307
+ if mode_dirs and mode_recursive:
308
+ print_error("Options --dirs and --recursive are mutually exclusive.")
309
+ return 1
310
+
311
+ base_dir = Path.cwd()
312
+ if mode_current:
313
+ targets = [base_dir]
314
+ elif mode_dirs:
315
+ targets = _iter_first_level_dirs(base_dir)
316
+ else:
317
+ targets = _iter_descendant_dirs(base_dir)
318
+
319
+ if mode_current or mode_dirs:
320
+ eligible_targets: list[Path] = []
321
+ for target_dir in targets:
322
+ if _is_git_repository_root(target_dir):
323
+ eligible_targets.append(target_dir)
324
+ else:
325
+ _print_install_skipped(target_dir)
326
+ targets = eligible_targets
327
+
328
+ print(
329
+ "-----------------------------------------------------------------------------------------------------------------"
330
+ )
331
+ print("Clean previous install")
332
+ print(
333
+ "-----------------------------------------------------------------------------------------------------------------"
334
+ )
335
+ for target_dir in targets:
336
+ print(f"Cleanup target: {target_dir}")
337
+ for evidence in _prepare_target_directory(target_dir):
338
+ _print_cleanup_evidence(evidence)
339
+ print()
340
+
341
+ for target_dir in targets:
342
+ print(
343
+ "-----------------------------------------------------------------------------------------------------------------"
344
+ )
345
+ print(f"Install useReq: {target_dir}")
346
+ print(
347
+ "-----------------------------------------------------------------------------------------------------------------"
348
+ )
349
+ if not target_dir.is_dir():
350
+ print_error(f"Path does not exist: {target_dir}")
351
+ return 1
352
+ try:
353
+ subprocess.run(_build_req_args(target_dir), check=True)
354
+ except subprocess.CalledProcessError as exc:
355
+ print_error(f"req failed for {target_dir} with exit code {exc.returncode}.")
356
+ return int(exc.returncode)
357
+ print(
358
+ "-----------------------------------------------------------------------------------------------------------------"
359
+ )
360
+ print()
361
+
362
+ return 0
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """@brief Pytest runner with managed project virtual environment.
3
+
4
+ @details Ensures required Python executables are available for the active flow
5
+ before creating/reusing `.venv`, optionally installing requirements, and running pytest.
6
+ @satisfies REQ-036, REQ-037, REQ-055, REQ-056
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import subprocess
12
+
13
+ from shell_scripts.utils import (
14
+ require_project_root,
15
+ print_info,
16
+ print_success,
17
+ require_commands,
18
+ )
19
+
20
+ PROGRAM = "shellscripts"
21
+ DESCRIPTION = "Run pytest test suite in a Python virtual environment."
22
+
23
+
24
+ def print_help(version):
25
+ print(f"Usage: {PROGRAM} tests [pytest-args...] ({version})")
26
+ print()
27
+ print("tests options:")
28
+ print(" [pytest-args...] - Arguments passed through to pytest.")
29
+ print(" --help - Show this help message.")
30
+
31
+
32
+ def run(args):
33
+ """@brief Execute managed pytest workflow with executable pre-checks.
34
+
35
+ @details Validates `sys.executable` and flow-conditional executables (`pip`,
36
+ `playwright`, `.venv/bin/python3`) before each subprocess invocation.
37
+ @param args {list[str]} Additional arguments forwarded to pytest.
38
+ @return {int} Subprocess return code from pytest execution.
39
+ @satisfies REQ-036, REQ-037, REQ-055, REQ-056
40
+ """
41
+
42
+ require_commands(sys.executable)
43
+ project_root = require_project_root()
44
+ os.chdir(project_root)
45
+
46
+ venv_dir = project_root / ".venv"
47
+
48
+ if not venv_dir.is_dir():
49
+ print_info("Creating virtual environment...")
50
+ subprocess.run(
51
+ [sys.executable, "-m", "venv", str(venv_dir)],
52
+ check=True,
53
+ )
54
+
55
+ pip = str(venv_dir / "bin" / "pip")
56
+ req_file = project_root / "requirements.txt"
57
+
58
+ if req_file.exists():
59
+ require_commands(pip)
60
+ print_info("Installing python requirements...")
61
+ subprocess.run(
62
+ [pip, "install", "-r", str(req_file)],
63
+ stdout=subprocess.DEVNULL,
64
+ check=True,
65
+ )
66
+
67
+ playwright = str(venv_dir / "bin" / "playwright")
68
+ if os.path.exists(playwright):
69
+ require_commands(playwright)
70
+ print_info("Installing Playwright Chromium...")
71
+ subprocess.run([playwright, "install", "chromium"])
72
+
73
+ python = str(venv_dir / "bin" / "python3")
74
+ require_commands(python)
75
+ env = os.environ.copy()
76
+ env["PYTHONPATH"] = str(project_root / "src") + ":" + env.get("PYTHONPATH", "")
77
+
78
+ result = subprocess.run(
79
+ [python, "-m", "pytest"] + args,
80
+ env=env,
81
+ )
82
+
83
+ if result.returncode != 0:
84
+ return result.returncode
85
+
86
+ print_success("Main test suite OK.")
87
+ return 0