-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_manifest.py
More file actions
303 lines (242 loc) · 11.4 KB
/
Copy pathgenerate_manifest.py
File metadata and controls
303 lines (242 loc) · 11.4 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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
#!/usr/bin/env python3
"""Generate the root manifest.json for the pydeck-plugins marketplace repo.
Usage
-----
python generate_manifest.py [options]
Options
-------
--label TEXT Catalog label string (default: "Canary")
--output PATH Output file path (default: manifest.json)
--dry-run Print the result to stdout instead of writing it
Discovery logic
---------------
For each plugins/<slug>/ directory:
1. Version directories are any sub-folders whose name parses as a semver
tuple (e.g. "1.0.0", "1.0.1"). They are sorted newest-first; the
highest becomes `latest`.
2. Per-version fields (name, description → summary, author,
min_pydeck_version, max_pydeck_version) are read from the version's
own manifest.json.
3. Catalog-only fields (category, compatible_pydeck_versions, summary
override) are read from an optional plugins/<slug>/catalog.json.
When that file is absent the script falls back to the matching entry
in the existing root manifest.json so nothing is lost on regeneration.
4. The icon path is auto-detected: icon.svg is preferred over icon.png.
Plugins are written in alphabetical order by name.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# ── Repo layout ────────────────────────────────────────────────────────────────
REPO_ROOT = Path(__file__).resolve().parent
PLUGINS_DIR = REPO_ROOT / "plugins"
ROOT_MANIFEST = REPO_ROOT / "manifest.json"
SCHEMA_VERSION = 1
DEFAULT_LABEL = "Canary"
ICON_PRIORITY = ("icon.svg", "icon.png")
# ── root_url ──────────────────────────────────────────────────────────────────
# The manifest is fetched from a vanity domain that proxies it, so consumers
# cannot derive where the files live from the URL they fetched. root_url states
# it outright: the base every icon_path / version path hangs off.
ROOT_URL_TEMPLATE = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}/"
def _git_output(*args: str) -> str:
import subprocess
try:
r = subprocess.run(["git", *args], cwd=REPO_ROOT,
capture_output=True, text=True)
except OSError:
return ""
return r.stdout.strip() if r.returncode == 0 else ""
def default_root_url() -> str:
"""Raw base for the checked-out branch, or "" when it cannot be determined.
Only a default: any script that writes a manifest for a branch other than
the one it is standing on must pass --root-url explicitly.
"""
remote = _git_output("remote", "get-url", "origin")
branch = _git_output("rev-parse", "--abbrev-ref", "HEAD")
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?/?$", remote)
if not m or not branch or branch == "HEAD":
return ""
return ROOT_URL_TEMPLATE.format(owner=m.group(1), repo=m.group(2), branch=branch)
# ── Semver helpers ─────────────────────────────────────────────────────────────
def _semver_tuple(version: str) -> Tuple[int, ...]:
"""Return a sortable tuple for a semver string, e.g. "1.0.1" → (1, 0, 1)."""
try:
return tuple(int(x) for x in version.split("."))
except ValueError:
return (0,)
def _is_version_dir(path: Path) -> bool:
"""True if *path* is a directory whose name looks like a semver string."""
if not path.is_dir():
return False
parts = path.name.split(".")
return len(parts) >= 2 and all(p.isdigit() for p in parts)
# ── Existing root manifest (for field fallbacks) ───────────────────────────────
def _load_existing_root() -> Dict[str, Dict[str, Any]]:
"""Return a slug → entry dict from the current root manifest, or {}."""
if not ROOT_MANIFEST.exists():
return {}
try:
data = json.loads(ROOT_MANIFEST.read_text())
return {p["slug"]: p for p in data.get("plugins", [])}
except (json.JSONDecodeError, KeyError):
return {}
# ── Per-plugin discovery ───────────────────────────────────────────────────────
def _icon_path(slug_dir: Path, slug: str) -> Optional[str]:
"""Return the repo-relative icon path, or None if no icon exists."""
for name in ICON_PRIORITY:
if (slug_dir / name).exists():
return f"plugins/{slug_dir.name}/{name}"
return None
def _catalog_meta(slug_dir: Path) -> Dict[str, Any]:
"""Read plugins/<slug>/catalog.json if it exists, else return {}."""
f = slug_dir / "catalog.json"
if not f.exists():
return {}
try:
return json.loads(f.read_text())
except json.JSONDecodeError as exc:
print(f" WARNING: {f} is invalid JSON — {exc}", file=sys.stderr)
return {}
def _read_version_manifest(version_dir: Path) -> Optional[Dict[str, Any]]:
"""Read and return the parsed manifest.json inside a version directory."""
f = version_dir / "manifest.json"
if not f.exists():
print(f" WARNING: missing {f}", file=sys.stderr)
return None
try:
return json.loads(f.read_text())
except json.JSONDecodeError as exc:
print(f" WARNING: {f} is invalid JSON — {exc}", file=sys.stderr)
return None
def _build_plugin_entry(
slug: str,
slug_dir: Path,
existing: Dict[str, Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Build a root-manifest plugin entry for *slug*, or None on failure."""
# ── Collect and sort version directories ──────────────────────────────────
version_dirs = sorted(
[d for d in slug_dir.iterdir() if _is_version_dir(d)],
key=lambda d: _semver_tuple(d.name),
)
if not version_dirs:
print(f" SKIP {slug}: no version directories found", file=sys.stderr)
return None
# ── Read all version manifests ────────────────────────────────────────────
versions: List[Dict[str, Any]] = []
latest_meta: Optional[Dict[str, Any]] = None
for vdir in version_dirs:
vmeta = _read_version_manifest(vdir)
if vmeta is None:
continue
versions.append({
"version": vdir.name,
"path": f"plugins/{slug_dir.name}/{vdir.name}",
"min_pydeck_version": vmeta.get("min_pydeck_version", None),
"max_pydeck_version": vmeta.get("max_pydeck_version", None),
})
latest_meta = vmeta # last (highest) version wins
if not versions or latest_meta is None:
print(f" SKIP {slug}: no readable version manifests", file=sys.stderr)
return None
latest_version = versions[-1]["version"]
# ── Resolve catalog-only fields ───────────────────────────────────────────
# Priority: catalog.json > existing root manifest > sensible defaults
catalog = _catalog_meta(slug_dir)
prev_entry = existing.get(slug, {})
name = latest_meta.get("name") or prev_entry.get("name") or slug
summary = (catalog.get("summary")
or prev_entry.get("summary")
or latest_meta.get("description", ""))
author = latest_meta.get("author") or prev_entry.get("author") or "Unknown"
category = (catalog.get("category")
or prev_entry.get("category")
or "utilities")
compat = (catalog.get("compatible_pydeck_versions")
or prev_entry.get("compatible_pydeck_versions")
or ["1.0"])
icon = _icon_path(slug_dir, slug) or prev_entry.get("icon_path")
if icon is None:
print(f" WARNING: {slug} has no icon file", file=sys.stderr)
icon = ""
return {
"name": name,
"slug": slug,
"category": category,
"summary": summary,
"author": author,
"latest": latest_version,
"icon_path": icon,
"compatible_pydeck_versions": compat,
"versions": versions,
}
# ── Main ───────────────────────────────────────────────────────────────────────
def generate(label: str, root_url: str, output: Path, dry_run: bool) -> None:
existing = _load_existing_root()
plugins: List[Dict[str, Any]] = []
slug_dirs = sorted(
[d for d in PLUGINS_DIR.iterdir() if d.is_dir()],
key=lambda d: d.name.lower(),
)
print(f"Scanning {len(slug_dirs)} plugin director{'y' if len(slug_dirs) == 1 else 'ies'}…")
for slug_dir in slug_dirs:
slug = slug_dir.name
entry = _build_plugin_entry(slug, slug_dir, existing)
if entry:
plugins.append(entry)
versions_str = ", ".join(v["version"] for v in entry["versions"])
print(f" ✓ {entry['name']} ({slug}) [{versions_str}] latest={entry['latest']}")
# Sort alphabetically by name
plugins.sort(key=lambda p: p["name"].lower())
root = {
"schema_version": SCHEMA_VERSION,
"label": label,
"root_url": root_url,
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"plugins": plugins,
}
output_text = json.dumps(root, indent=2, ensure_ascii=False) + "\n"
if dry_run:
print("\n── dry-run output ──────────────────────────────────────────────")
print(output_text)
else:
output.write_text(output_text)
print(f"\nWrote {len(plugins)} plugin(s) → {output.relative_to(REPO_ROOT)}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate the root manifest.json for the pydeck-plugins repo.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--label",
default=DEFAULT_LABEL,
help=f'Catalog label (default: "{DEFAULT_LABEL}")',
)
parser.add_argument(
"--root-url",
default=default_root_url(),
help="Base URL the entry paths resolve against "
"(default: the raw URL of the checked-out branch)",
)
parser.add_argument(
"--output",
type=Path,
default=ROOT_MANIFEST,
help=f"Output path (default: {ROOT_MANIFEST.name})",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the generated JSON to stdout without writing any file",
)
args = parser.parse_args()
generate(label=args.label, root_url=args.root_url,
output=args.output, dry_run=args.dry_run)
if __name__ == "__main__":
main()