-
Notifications
You must be signed in to change notification settings - Fork 47
Expand file tree
/
Copy pathpackage_pcm.py
More file actions
269 lines (222 loc) · 9.58 KB
/
Copy pathpackage_pcm.py
File metadata and controls
269 lines (222 loc) · 9.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/usr/bin/env python3
"""
Build a KiCad PCM (Plugin and Content Manager) zip for this project.
The KiCad PCM expects a zip with this layout (see go.kicad.org/pcm/schemas/v1):
metadata.json
resources/icon.png
plugins/__init__.py
plugins/...everything else...
KiCad PCM's metadata schema rejects multiple `versions[]` entries with the
same `version` string, so we ship ONE zip per version that bundles binaries
for all supported platforms (linux x86_64, macOS arm64, macOS x86_64, windows
x86_64) under platform-suffix filenames. The plugin's root __init__.py
picks the right binary at startup.
The metadata.json inside the zip is also constrained: it must have exactly
one version entry and no download_* fields. This script generates that
"inner" metadata from the repository-style metadata.json automatically.
Usage:
# Use already-downloaded binaries from a directory:
python package_pcm.py --binary-dir ./artifacts
# Or download all 4 binaries from the matching GitHub Release:
python package_pcm.py
# Override the version (default: read from VERSION file)
python package_pcm.py --version 0.15.5
Output: dist/KiCadRoutingTools-<version>.zip plus a .meta.json sidecar
containing sha256/download_size/install_size for metadata.json patching.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import shutil
import sys
import tempfile
import urllib.request
import zipfile
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
GITHUB_REPO = "drandyhaas/KiCadRoutingTools"
# The plugin ships the whole repo working tree minus the entries below, so a
# PCM install contains the same code as git: the GUI runs, and the command-line
# tools work straight from the installed plugin directory. Using a denylist
# (rather than an allowlist of files/dirs) means new modules and packages ship
# automatically -- forgetting to allowlist a package was issue #49.
ROOT_EXCLUDE = {
# Build / release / install tooling: not needed at runtime.
"build_router.py",
"install_plugin.py",
"package_pcm.py",
"update_metadata.py",
# The repo metadata.json is rewritten to the zip root separately by
# write_top_level(); it must not also appear under plugins/.
"metadata.json",
# Dev-only directories.
"tests",
"docs",
"kicad_files", # sample boards: large and unnecessary at runtime
"artifacts", # CI downloads release binaries here before packaging
}
# CLAUDE.md and .claude/ (the routing skills) ship intentionally so they can be
# used from the installed plugin later.
# Names skipped anywhere in the tree (VCS, caches, build artifacts). The Rust
# crate's build dir and the per-platform binaries are stripped here; the right
# binary is added back under platform-suffix names by install_binaries().
IGNORE_PATTERNS = shutil.ignore_patterns(
".git", ".github", ".gitignore", ".DS_Store",
"__pycache__", "*.pyc", "dist",
".pytest_cache", ".mypy_cache", ".ruff_cache", ".venv",
"target", # rust_router/target/ is the cargo build dir
"grid_router.so", "grid_router.pyd", "grid_router.abi3.so",
"Cargo.lock",
)
# All binaries bundled in every PCM zip. The startup resolver in the root
# __init__.py picks the right one based on sys.platform + machine.
ALL_BINARIES = [
"grid_router-linux-x86_64.so",
"grid_router-macos-arm64.so",
"grid_router-macos-x86_64.so",
"grid_router-windows-x86_64.pyd",
]
def read_version():
return (SCRIPT_DIR / "VERSION").read_text().strip()
def stage_plugins(stage_root: Path, binary_dir: Path | None = None):
"""Copy the repo working tree (minus ROOT_EXCLUDE / IGNORE_PATTERNS) into
<stage_root>/plugins/.
binary_dir, when it lives inside the repo (CI downloads release binaries to
./artifacts before packaging), is skipped so it isn't swept into the plugin.
"""
plugins_dir = stage_root / "plugins"
plugins_dir.mkdir(parents=True, exist_ok=True)
ignored_names = set(ROOT_EXCLUDE)
if binary_dir is not None:
try:
ignored_names.add(binary_dir.resolve().relative_to(SCRIPT_DIR).parts[0])
except (ValueError, IndexError):
pass # binary_dir is outside the repo; nothing to skip
for entry in sorted(SCRIPT_DIR.iterdir()):
if entry.name in ignored_names:
continue
# IGNORE_PATTERNS handles dotfiles/caches uniformly at the top level too.
if IGNORE_PATTERNS(str(SCRIPT_DIR), [entry.name]):
continue
dst = plugins_dir / entry.name
if entry.is_dir():
shutil.copytree(entry, dst, ignore=IGNORE_PATTERNS)
else:
shutil.copy2(entry, dst)
return plugins_dir
def _download_asset(version: str, asset_name: str, dest: Path):
url = (
f"https://github.com/{GITHUB_REPO}/releases/download/"
f"v{version}/{asset_name}"
)
print(f" Downloading {url}")
req = urllib.request.Request(url, headers={"User-Agent": "package_pcm.py"})
with urllib.request.urlopen(req, timeout=120) as resp, open(dest, "wb") as f:
shutil.copyfileobj(resp, f)
def install_binaries(plugins_dir: Path, version: str, binary_dir: Path | None):
"""Place ALL platform Rust binaries into plugins/rust_router/ under their
platform-suffix names. The plugin's startup resolver picks the right one.
"""
rust_dir = plugins_dir / "rust_router"
rust_dir.mkdir(parents=True, exist_ok=True)
for name in ALL_BINARIES:
dst = rust_dir / name
if binary_dir is not None:
src = binary_dir / name
if not src.exists():
raise FileNotFoundError(f"Expected {src}")
shutil.copy2(src, dst)
else:
_download_asset(version, name, dst)
def write_top_level(stage_root: Path, version: str):
"""Place metadata.json (stripped to one version, no download_* fields)
and resources/icon.png at the zip root.
"""
repo_meta = json.loads((SCRIPT_DIR / "metadata.json").read_text())
# Find the version entry that matches the package version, then strip it
# of download_* fields. The PCM repo's validator requires the in-package
# metadata.json to have exactly one version entry and no download_*.
matches = [v for v in repo_meta["versions"] if v.get("version") == version]
if not matches:
raise ValueError(
f"metadata.json has no version entry for {version}; "
f"available: {[v.get('version') for v in repo_meta['versions']]}"
)
inner_version = copy.deepcopy(matches[0])
for key in ("download_url", "download_sha256", "download_size", "install_size"):
inner_version.pop(key, None)
inner_meta = copy.deepcopy(repo_meta)
inner_meta["versions"] = [inner_version]
(stage_root / "metadata.json").write_text(
json.dumps(inner_meta, indent=2) + "\n"
)
resources = stage_root / "resources"
resources.mkdir(exist_ok=True)
icon_src = SCRIPT_DIR / "kicad_routing_plugin" / "icon_64.png"
if not icon_src.exists():
raise FileNotFoundError(f"Missing icon: {icon_src}")
shutil.copy2(icon_src, resources / "icon.png")
def make_zip(stage_root: Path, out_zip: Path):
"""Zip the staging tree."""
if out_zip.exists():
out_zip.unlink()
install_size = 0
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for path in sorted(stage_root.rglob("*")):
rel = path.relative_to(stage_root)
if path.is_dir():
continue
install_size += path.stat().st_size
zf.write(path, rel.as_posix())
return install_size
def sha256_of(path: Path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--version", default=None,
help="Override version (default: read VERSION file)")
parser.add_argument("--binary-dir", default=None,
help="Directory containing pre-fetched release assets")
parser.add_argument("--out-dir", default=str(SCRIPT_DIR / "dist"),
help="Output directory (default: ./dist)")
args = parser.parse_args()
version = args.version or read_version()
binary_dir = Path(args.binary_dir).resolve() if args.binary_dir else None
out_dir = Path(args.out_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
zip_name = f"KiCadRoutingTools-{version}.zip"
out_zip = out_dir / zip_name
print(f"Building PCM package: {zip_name}")
with tempfile.TemporaryDirectory(prefix="kicadrt-pcm-") as tmp:
stage_root = Path(tmp)
plugins_dir = stage_plugins(stage_root, binary_dir)
install_binaries(plugins_dir, version, binary_dir)
write_top_level(stage_root, version)
install_size = make_zip(stage_root, out_zip)
download_size = out_zip.stat().st_size
digest = sha256_of(out_zip)
print()
print(f" Output: {out_zip}")
print(f" download_sha256: {digest}")
print(f" download_size: {download_size}")
print(f" install_size: {install_size}")
sidecar = out_dir / f"{zip_name}.meta.json"
sidecar.write_text(json.dumps({
"version": version,
"filename": zip_name,
"download_sha256": digest,
"download_size": download_size,
"install_size": install_size,
}, indent=2) + "\n")
print(f" sidecar: {sidecar}")
if __name__ == "__main__":
main()