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.
- shell_scripts/__init__.py +5 -0
- shell_scripts/__main__.py +5 -0
- shell_scripts/commands/__init__.py +81 -0
- shell_scripts/commands/_dc_common.py +138 -0
- shell_scripts/commands/ai_install.py +453 -0
- shell_scripts/commands/bin_links.py +78 -0
- shell_scripts/commands/claude.py +56 -0
- shell_scripts/commands/clean.py +98 -0
- shell_scripts/commands/codex.py +107 -0
- shell_scripts/commands/copilot.py +54 -0
- shell_scripts/commands/dicom2jpg.py +98 -0
- shell_scripts/commands/dicomviewer.py +83 -0
- shell_scripts/commands/diff_cmd.py +50 -0
- shell_scripts/commands/doxygen_cmd.py +159 -0
- shell_scripts/commands/edit_cmd.py +50 -0
- shell_scripts/commands/gemini.py +54 -0
- shell_scripts/commands/kiro.py +54 -0
- shell_scripts/commands/opencode.py +54 -0
- shell_scripts/commands/pdf_crop.py +520 -0
- shell_scripts/commands/pdf_merge.py +180 -0
- shell_scripts/commands/pdf_split_by_format.py +243 -0
- shell_scripts/commands/pdf_split_by_toc.py +197 -0
- shell_scripts/commands/pdf_tiler_090.py +61 -0
- shell_scripts/commands/pdf_tiler_100.py +61 -0
- shell_scripts/commands/pdf_toc_clean.py +175 -0
- shell_scripts/commands/pi.py +61 -0
- shell_scripts/commands/req_cmd.py +362 -0
- shell_scripts/commands/tests_cmd.py +87 -0
- shell_scripts/commands/ubuntu_dark_theme.py +64 -0
- shell_scripts/commands/venv_cmd.py +82 -0
- shell_scripts/commands/video2h264.py +89 -0
- shell_scripts/commands/video2h265.py +87 -0
- shell_scripts/commands/view_cmd.py +50 -0
- shell_scripts/commands/vscode_cmd.py +42 -0
- shell_scripts/commands/vsinsider_cmd.py +42 -0
- shell_scripts/config.py +328 -0
- shell_scripts/core.py +324 -0
- shell_scripts/utils.py +455 -0
- shell_scripts/version_check.py +323 -0
- shellscripts-0.23.1.dist-info/METADATA +310 -0
- shellscripts-0.23.1.dist-info/RECORD +45 -0
- shellscripts-0.23.1.dist-info/WHEEL +5 -0
- shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
- shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
- 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
|