返回 ppt-master
update_repo.py
根目录 / skills / ppt-master / scripts / update_repo.py
1 #!/usr/bin/env python3
2 """PPT Master - Repository Updater
3
4 Pull the latest Git checkout and sync Python dependencies when the effective
5 requirements include tree changes.
6
7 Usage:
8 python3 skills/ppt-master/scripts/update_repo.py
9 python3 skills/ppt-master/scripts/update_repo.py --skip-pip
10
11 Examples:
12 python3 skills/ppt-master/scripts/update_repo.py
13 python3 skills/ppt-master/scripts/update_repo.py --skip-pip
14
15 Dependencies:
16 None (standard library only)
17 """
18
19 from __future__ import annotations
20
21 import argparse
22 import hashlib
23 import shlex
24 import shutil
25 import subprocess
26 import sys
27 from pathlib import Path
28
29 from console_encoding import configure_utf8_stdio
30
31 configure_utf8_stdio()
32
33
34 TOOLS_DIR = Path(__file__).resolve().parent
35 SKILL_DIR = TOOLS_DIR.parent
36 REPO_ROOT = SKILL_DIR.parent.parent
37 REQUIREMENTS_FILE = REPO_ROOT / "requirements.txt"
38
39
40 def non_git_checkout_message() -> str:
41 return f"""This copy of PPT Master is not a Git checkout, so it cannot be updated automatically.
42
43 Repository path:
44 {REPO_ROOT}
45
46 If you installed with Download ZIP:
47 1. Download the latest ZIP from GitHub or AtomGit.
48 2. Unzip it into a new folder.
49 3. Copy your old .env and projects/ folder into the new folder.
50 4. Run: pip install -r requirements.txt
51
52 If you want one-command updates next time, install with Git clone:
53 git clone https://github.com/hugohe3/ppt-master.git
54
55 If you installed through a skill marketplace, update or reinstall through the
56 same marketplace / skills tool."""
57
58
59 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
60 parser = argparse.ArgumentParser(
61 description=(
62 "Pull the latest repository changes and sync Python dependencies "
63 "only when the requirements include tree changes."
64 ),
65 formatter_class=argparse.RawDescriptionHelpFormatter,
66 )
67 parser.add_argument(
68 "--skip-pip",
69 action="store_true",
70 help="Skip Python dependency sync even if the requirements include tree changed.",
71 )
72 return parser.parse_args(argv)
73
74
75 def print_status(message: str = "") -> None:
76 """Print progress/status messages to stderr."""
77 print(message, file=sys.stderr)
78
79
80 def run_command(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
81 return subprocess.run(
82 args,
83 cwd=REPO_ROOT,
84 check=check,
85 capture_output=True,
86 text=True,
87 encoding="utf-8",
88 errors="replace",
89 )
90
91
92 def _requirement_includes(path: Path, content: str) -> list[Path]:
93 """Resolve local -r/--requirement includes relative to their owning file."""
94 includes: list[Path] = []
95 for raw_line in content.splitlines():
96 try:
97 tokens = shlex.split(raw_line, comments=True, posix=True)
98 except ValueError:
99 continue
100 if not tokens:
101 continue
102
103 option = tokens[0]
104 include_value: str | None = None
105 if option in {"-r", "--requirement"}:
106 if len(tokens) >= 2:
107 include_value = tokens[1]
108 elif option.startswith("--requirement="):
109 include_value = option.partition("=")[2]
110 elif option.startswith("-r") and len(option) > 2:
111 include_value = option[2:].lstrip("=")
112
113 if not include_value:
114 continue
115 include_path = Path(include_value)
116 if not include_path.is_absolute():
117 include_path = path.parent / include_path
118 includes.append(include_path)
119 return includes
120
121
122 def requirements_digest(path: Path) -> str | None:
123 """Hash one requirements file and its recursive local include closure."""
124 if not path.exists():
125 return None
126
127 digest = hashlib.sha256()
128 visited: set[Path] = set()
129
130 def visit(current: Path) -> None:
131 try:
132 resolved = current.resolve()
133 except OSError as exc:
134 raise RuntimeError(
135 f"Unable to resolve requirements file {current}: {exc}"
136 ) from exc
137 if resolved in visited:
138 digest.update(b"repeat\0")
139 return
140 visited.add(resolved)
141
142 if not resolved.is_file():
143 digest.update(b"missing\0")
144 return
145 try:
146 content = resolved.read_bytes()
147 except OSError as exc:
148 raise RuntimeError(
149 f"Unable to read requirements file {resolved}: {exc}"
150 ) from exc
151 digest.update(b"file\0")
152 digest.update(len(content).to_bytes(8, "big"))
153 digest.update(content)
154 for included in _requirement_includes(
155 resolved,
156 content.decode("utf-8", errors="replace"),
157 ):
158 visit(included)
159
160 visit(path)
161 return digest.hexdigest()
162
163
164 def ensure_git_available() -> None:
165 if shutil.which("git") is None:
166 raise RuntimeError("Missing executable: git")
167
168
169 def ensure_git_checkout() -> None:
170 if not (REPO_ROOT / ".git").exists():
171 raise RuntimeError(non_git_checkout_message())
172
173
174 def ensure_clean_tracked_worktree() -> None:
175 status = run_command(["git", "status", "--porcelain", "--untracked-files=no"], check=False)
176 if status.returncode != 0:
177 details = (status.stderr or status.stdout or "").strip()
178 raise RuntimeError(details or "Unable to inspect git status.")
179
180 if status.stdout.strip():
181 raise RuntimeError(
182 "Tracked local changes detected. Please commit or stash them before running the update command."
183 )
184
185
186 def get_head_revision() -> str:
187 result = run_command(["git", "rev-parse", "HEAD"])
188 return result.stdout.strip()
189
190
191 def sync_python_dependencies() -> None:
192 if not REQUIREMENTS_FILE.exists():
193 print_status("requirements.txt not found; skipping Python dependency sync.")
194 return
195
196 print_status("Requirements include tree changed. Syncing Python dependencies...")
197 result = run_command([sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE)])
198 if result.stdout.strip():
199 print_status(result.stdout.strip())
200 if result.stderr.strip():
201 print_status(result.stderr.strip())
202
203
204 def main(argv: list[str] | None = None) -> int:
205 args = parse_args(argv)
206
207 try:
208 ensure_git_checkout()
209 ensure_git_available()
210 ensure_clean_tracked_worktree()
211
212 before_head = get_head_revision()
213 before_requirements = requirements_digest(REQUIREMENTS_FILE)
214
215 print_status(f"Repository: {REPO_ROOT}")
216 pull_result = run_command(["git", "pull", "--ff-only"])
217 if pull_result.stdout.strip():
218 print_status(pull_result.stdout.strip())
219 if pull_result.stderr.strip():
220 print_status(pull_result.stderr.strip())
221
222 after_head = get_head_revision()
223 after_requirements = requirements_digest(REQUIREMENTS_FILE)
224
225 if before_head == after_head:
226 print_status("Repository is already up to date.")
227 else:
228 print_status(f"Updated from {before_head[:7]} to {after_head[:7]}.")
229
230 if args.skip_pip:
231 print_status("Skipped Python dependency sync (--skip-pip).")
232 elif before_requirements != after_requirements:
233 sync_python_dependencies()
234 else:
235 print_status(
236 "Requirements include tree unchanged. Skipping Python dependency sync."
237 )
238
239 print_status(
240 "Note: system dependencies such as Node.js and Pandoc still need "
241 "to be installed manually."
242 )
243 return 0
244 except subprocess.CalledProcessError as exc:
245 details = (exc.stderr or exc.stdout or "").strip()
246 print(details or "Command failed.", file=sys.stderr)
247 return exc.returncode or 1
248 except RuntimeError as exc:
249 print(str(exc), file=sys.stderr)
250 return 1
251
252
253 if __name__ == "__main__":
254 raise SystemExit(main())
255
255 lines PYTHON