snap-maintainer 0.1.0__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.
@@ -0,0 +1 @@
1
+ """Discover and maintain published snaps."""
@@ -0,0 +1,3 @@
1
+ from snap_maintainer.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,143 @@
1
+ """Ubuntu archive metadata: which binary packages a snap stages, and their source packages.
2
+
3
+ snapcraft doesn't record staged package versions in the snap any more, so we
4
+ reconstruct *which* packages are in it: the stage-packages plus their
5
+ Depends/Pre-Depends closure, minus what the base snap already ships (craft-parts
6
+ leaves those out too).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import lzma
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from snap_maintainer import debver
16
+ from snap_maintainer.cache import cached_json, fetch
17
+
18
+ COMPONENTS = ("main", "universe", "restricted", "multiverse")
19
+ POCKETS = ("", "-updates", "-security")
20
+
21
+
22
+ def _mirror(arch: str) -> str:
23
+ if arch in ("amd64", "i386"):
24
+ return "http://archive.ubuntu.com/ubuntu"
25
+ return "http://ports.ubuntu.com/ubuntu-ports"
26
+
27
+
28
+ def _parse_relations(field: str) -> list[list[str]]:
29
+ """'a (>= 1) | b, c:any' -> [['a', 'b'], ['c']]"""
30
+ groups = []
31
+ for group in field.split(","):
32
+ alts = [re.split(r"[\s(:]", alt.strip(), maxsplit=1)[0] for alt in group.split("|")]
33
+ alts = [a for a in alts if a]
34
+ if alts:
35
+ groups.append(alts)
36
+ return groups
37
+
38
+
39
+ def parse_packages(text: str, index: dict) -> None:
40
+ """Merge a Packages file into ``index`` =
41
+ {pkg: {"source": str, "version": source version, "deps": [[alts]]},
42
+ "__provides__": {virtual: [pkgs]}}, keeping the newest version of each package."""
43
+ provides = index.setdefault("__provides__", {})
44
+ for stanza in text.split("\n\n"):
45
+ fields: dict[str, str] = {}
46
+ key = None
47
+ for line in stanza.splitlines():
48
+ if line.startswith((" ", "\t")) and key:
49
+ fields[key] += " " + line.strip()
50
+ elif ":" in line:
51
+ key, _, value = line.partition(":")
52
+ fields[key] = value.strip()
53
+ name = fields.get("Package")
54
+ if not name:
55
+ continue
56
+ version = fields.get("Version", "")
57
+ old = index.get(name)
58
+ if old and debver.compare(old["bin_version"], version) >= 0:
59
+ continue
60
+ # "Source: foo (1.2-3)" when the source version differs from the binary's
61
+ src_field = fields.get("Source", name)
62
+ source = src_field.split(" ")[0]
63
+ m = re.search(r"\(([^)]+)\)", src_field)
64
+ deps = _parse_relations(fields.get("Pre-Depends", "")) + _parse_relations(fields.get("Depends", ""))
65
+ index[name] = {"source": source, "version": m.group(1) if m else version,
66
+ "bin_version": version, "deps": deps}
67
+ for virt in _parse_relations(fields.get("Provides", "")):
68
+ lst = provides.setdefault(virt[0], [])
69
+ if name not in lst:
70
+ lst.append(name)
71
+
72
+
73
+ def package_index(series: str, arch: str, refresh: bool = False) -> dict:
74
+ def build() -> dict:
75
+ index: dict = {}
76
+ for pocket in POCKETS:
77
+ for comp in COMPONENTS:
78
+ url = f"{_mirror(arch)}/dists/{series}{pocket}/{comp}/binary-{arch}/Packages.xz"
79
+ try:
80
+ data = lzma.decompress(fetch(url))
81
+ except Exception:
82
+ continue # e.g. an empty pocket/component
83
+ parse_packages(data.decode("utf-8", "replace"), index)
84
+ if len(index) < 100:
85
+ raise RuntimeError(f"could not download the {series}/{arch} package index")
86
+ return index
87
+ return cached_json(f"packages-{series}-{arch}", build, refresh=refresh)
88
+
89
+
90
+ def base_packages(base: str) -> set[str] | None:
91
+ """Packages shipped in the base snap, from its dpkg.list (None if the base isn't installed)."""
92
+ path = Path(f"/snap/{base}/current/usr/share/snappy/dpkg.list")
93
+ if not path.exists():
94
+ return None
95
+ pkgs = set()
96
+ for line in path.read_text().splitlines():
97
+ if line.startswith("ii "):
98
+ pkgs.add(line.split()[1].split(":")[0])
99
+ return pkgs
100
+
101
+
102
+ def closure(roots: list[str], index: dict, exclude: set[str]) -> tuple[set[str], list[str]]:
103
+ """Binary packages pulled in by ``roots``; returns (packages, names not found)."""
104
+ provides = index.get("__provides__", {})
105
+ seen: set[str] = set()
106
+ missing: list[str] = []
107
+ todo = list(roots)
108
+ explicit = set(roots)
109
+ while todo:
110
+ pkg = todo.pop()
111
+ if pkg in seen:
112
+ continue
113
+ if pkg not in index:
114
+ real = provides.get(pkg)
115
+ if not real:
116
+ missing.append(pkg)
117
+ continue
118
+ pkg = real[0]
119
+ if pkg in seen:
120
+ continue
121
+ if pkg in exclude and pkg not in explicit:
122
+ continue
123
+ seen.add(pkg)
124
+ for alts in index[pkg]["deps"]:
125
+ # already satisfied by the base or by something we're staging
126
+ if any(a in exclude or a in seen for a in alts):
127
+ continue
128
+ todo.append(next((a for a in alts if a in index), alts[0]))
129
+ return seen, missing
130
+
131
+
132
+ def sources_of(packages: set[str], index: dict) -> dict[str, dict]:
133
+ """{source package: {"binaries": [...], "version": newest source version in the archive}}"""
134
+ out: dict[str, dict] = {}
135
+ for pkg in packages:
136
+ entry = out.setdefault(index[pkg]["source"], {"binaries": [], "version": None})
137
+ entry["binaries"].append(pkg)
138
+ v = index[pkg]["version"]
139
+ if entry["version"] is None or debver.compare(v, entry["version"]) > 0:
140
+ entry["version"] = v
141
+ for entry in out.values():
142
+ entry["binaries"].sort()
143
+ return out
@@ -0,0 +1,205 @@
1
+ """Milestone 3a: work out how each snap gets built and uploaded (detection only).
2
+
3
+ Builders, in order of preference when several apply:
4
+ github-workflow a GitHub Actions workflow builds the snap (and usually uploads it)
5
+ launchpad a Launchpad snap recipe builds from the repo (and usually uploads it)
6
+ local nothing automated found: build with snapcraft on this machine
7
+ manual set by the user: rebuilds happen outside this tool
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import urllib.request
15
+
16
+ import yaml
17
+
18
+ from snap_maintainer import repo as repo_mod
19
+
20
+ METHODS = ("github-workflow", "launchpad", "local", "manual")
21
+
22
+ LP_SNAPS_BY_PREFIX = ("https://api.launchpad.net/devel/+snaps?ws.op=findByURLPrefix"
23
+ "&url_prefix={prefix}&ws.size=300")
24
+
25
+ _BUILD_ACTIONS = ("snapcore/action-build", "canonical/action-build")
26
+ _PUBLISH_ACTIONS = ("snapcore/action-publish", "canonical/action-publish")
27
+ _BUILD_CMD = re.compile(r"\bsnapcraft(\s+(pack|snap|remote-build)\b|\s*$|\s+--(?!classic))")
28
+ _UPLOAD_CMD = re.compile(r"\bsnapcraft\s+(upload|push)\b")
29
+ _RELEASE_ARG = re.compile(r"--release[= ]+[\"']?([\w/,.-]+)")
30
+
31
+
32
+ def _runs_build(run: str) -> bool:
33
+ """A `run:` script that builds a snap (`snapcraft`, `snapcraft pack`, ...), ignoring
34
+ lines that merely install snapcraft."""
35
+ return any(_BUILD_CMD.search(line) for line in run.splitlines()
36
+ if "install" not in line and not line.lstrip().startswith("#"))
37
+
38
+
39
+ def _triggers(doc: dict) -> dict:
40
+ """Normalise a workflow's `on:` (which YAML 1.1 parses as the key True)."""
41
+ on = doc.get("on", doc.get(True))
42
+ if isinstance(on, str):
43
+ on = {on: None}
44
+ elif isinstance(on, list):
45
+ on = {k: None for k in on}
46
+ elif not isinstance(on, dict):
47
+ on = {}
48
+ out = {"dispatch": "workflow_dispatch" in on, "tags": [], "branches": [],
49
+ "release": "release" in on, "schedule": "schedule" in on, "push": "push" in on}
50
+ push = on.get("push") or {}
51
+ if isinstance(push, dict):
52
+ out["tags"] = [str(t) for t in push.get("tags") or []]
53
+ out["branches"] = [str(b) for b in push.get("branches") or []]
54
+ return out
55
+
56
+
57
+ def analyze_workflow(path: str, text: str) -> dict | None:
58
+ """What a workflow does with snaps, or None if it doesn't build one."""
59
+ try:
60
+ doc = yaml.safe_load(text) or {}
61
+ except yaml.YAMLError:
62
+ return None
63
+ if not isinstance(doc, dict):
64
+ return None
65
+ builds = uploads = False
66
+ how_build = how_upload = None
67
+ channels: set[str] = set()
68
+ for job in (doc.get("jobs") or {}).values():
69
+ for step in (job or {}).get("steps") or []:
70
+ if not isinstance(step, dict):
71
+ continue
72
+ uses = str(step.get("uses") or "")
73
+ run = str(step.get("run") or "")
74
+ if uses.startswith(_BUILD_ACTIONS):
75
+ builds, how_build = True, uses.split("@")[0]
76
+ elif _runs_build(run):
77
+ builds, how_build = True, how_build or "snapcraft"
78
+ if uses.startswith(_PUBLISH_ACTIONS):
79
+ uploads, how_upload = True, uses.split("@")[0]
80
+ rel = str((step.get("with") or {}).get("release") or "")
81
+ if rel and "$" not in rel:
82
+ channels.add(rel)
83
+ elif _UPLOAD_CMD.search(run):
84
+ uploads, how_upload = True, how_upload or "snapcraft upload"
85
+ for m in _RELEASE_ARG.finditer(run):
86
+ if "$" not in m.group(1):
87
+ channels.add(m.group(1))
88
+ if not builds:
89
+ return None
90
+ return {
91
+ "path": path,
92
+ "name": str(doc.get("name") or path.rsplit("/", 1)[-1]),
93
+ "builds_with": how_build,
94
+ "uploads": uploads,
95
+ "uploads_with": how_upload,
96
+ "channels": sorted(channels), # empty: none, or decided at run time
97
+ "triggers": _triggers(doc),
98
+ }
99
+
100
+
101
+ def describe_trigger(t: dict) -> str:
102
+ """Short human description of how a workflow gets started."""
103
+ parts = []
104
+ if t.get("tags"):
105
+ parts.append("tag " + ", ".join(t["tags"]))
106
+ if t.get("release"):
107
+ parts.append("GitHub release")
108
+ if t.get("dispatch"):
109
+ parts.append("manual dispatch")
110
+ if t.get("branches"):
111
+ parts.append("push to " + ", ".join(t["branches"]))
112
+ elif t.get("push") and not t.get("tags"):
113
+ parts.append("any push")
114
+ if t.get("schedule"):
115
+ parts.append("schedule")
116
+ return " or ".join(parts) or "unknown trigger"
117
+
118
+
119
+ def workflow_candidates(repo: dict) -> list[dict]:
120
+ out = []
121
+ for path in repo.get("workflows") or []:
122
+ code, text = repo_mod.run_gh([
123
+ "api", "-H", "Accept: application/vnd.github.raw",
124
+ f"repos/{repo['owner']}/{repo['name']}/contents/{path}"
125
+ f"?ref={repo.get('default_branch') or 'HEAD'}",
126
+ ])
127
+ if code == 0:
128
+ wf = analyze_workflow(path, text)
129
+ if wf:
130
+ out.append(wf)
131
+ return out
132
+
133
+
134
+ def launchpad_recipes(owner: str) -> list[dict] | None:
135
+ """Launchpad snap recipes building from github.com/<owner>/*, or None if LP is unreachable."""
136
+ url = LP_SNAPS_BY_PREFIX.format(prefix=f"https://github.com/{owner}/")
137
+ try:
138
+ with urllib.request.urlopen(url, timeout=30) as resp:
139
+ data = json.load(resp)
140
+ except Exception:
141
+ return None
142
+ return [
143
+ {
144
+ "name": e.get("name"),
145
+ "owner": (e.get("owner_link") or "").rsplit("~", 1)[-1] or None,
146
+ "url": e.get("web_link"),
147
+ "git_url": e.get("git_repository_url"),
148
+ "git_path": e.get("git_path"),
149
+ "store_name": e.get("store_name"),
150
+ "store_upload": bool(e.get("store_upload")),
151
+ "auto_build": bool(e.get("auto_build")),
152
+ "channels": e.get("store_channels") or [],
153
+ }
154
+ for e in data.get("entries", [])
155
+ ]
156
+
157
+
158
+ def _same_repo(a: str | None, b: str) -> bool:
159
+ return bool(a) and repo_mod.normalize(a).lower() == repo_mod.normalize(b).lower()
160
+
161
+
162
+ def detect(snap_name: str, repo: dict, lp: list[dict] | None) -> dict:
163
+ """Pick a builder for one snap and record the evidence and the alternatives."""
164
+ candidates: list[dict] = []
165
+ notes: list[str] = []
166
+
167
+ for wf in workflow_candidates(repo) if repo.get("verified") else []:
168
+ what = "builds and uploads" if wf["uploads"] else "builds (no upload)"
169
+ candidates.append({
170
+ "method": "github-workflow",
171
+ "rank": 0 if wf["uploads"] else 2,
172
+ "workflow": wf,
173
+ "evidence": f"{wf['path']} {what}; runs on {describe_trigger(wf['triggers'])}",
174
+ })
175
+
176
+ if lp is None:
177
+ notes.append("Launchpad couldn't be reached; Launchpad recipes weren't checked")
178
+ for r in lp or []:
179
+ if not (_same_repo(r["git_url"], repo.get("url", "")) or
180
+ (repo.get("redirected_from") and _same_repo(r["git_url"], repo["redirected_from"]))):
181
+ continue
182
+ if r["store_name"] and r["store_name"] != snap_name:
183
+ continue
184
+ what = "builds and uploads" if r["store_upload"] else "builds (no upload)"
185
+ auto = ", automatically on new commits" if r["auto_build"] else ""
186
+ candidates.append({
187
+ "method": "launchpad",
188
+ "rank": 1 if r["store_upload"] else 3,
189
+ "recipe": r,
190
+ "evidence": f"Launchpad recipe {r['name']} {what}{auto}",
191
+ })
192
+
193
+ candidates.sort(key=lambda c: c["rank"])
194
+ if candidates:
195
+ best = candidates[0]
196
+ chosen = {k: v for k, v in best.items() if k != "rank"}
197
+ else:
198
+ chosen = {"method": "local",
199
+ "evidence": "no workflow or Launchpad recipe builds this snap"}
200
+ if not repo.get("snapcraft_yaml"):
201
+ notes.append("no snapcraft.yaml found, so a local build isn't possible yet")
202
+ chosen["source"] = "detected"
203
+ chosen["alternatives"] = [c["evidence"] for c in candidates[1:]]
204
+ chosen["notes"] = notes
205
+ return chosen
@@ -0,0 +1,38 @@
1
+ """Small on-disk cache for slow-to-fetch, slow-to-parse upstream data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ DEFAULT_TTL = 24 * 3600
13
+
14
+
15
+ def cache_dir() -> Path:
16
+ base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
17
+ d = base / "snap-maintainer"
18
+ d.mkdir(parents=True, exist_ok=True)
19
+ return d
20
+
21
+
22
+ def fetch(url: str, timeout: int = 120) -> bytes:
23
+ req = urllib.request.Request(url, headers={"User-Agent": "snap-maintainer"})
24
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
25
+ return resp.read()
26
+
27
+
28
+ def cached_json(key: str, build: Callable[[], object], ttl: int = DEFAULT_TTL,
29
+ refresh: bool = False) -> object:
30
+ """Return the cached JSON value for ``key``, rebuilding it when stale."""
31
+ path = cache_dir() / f"{key}.json"
32
+ if not refresh and path.exists() and time.time() - path.stat().st_mtime < ttl:
33
+ return json.loads(path.read_text())
34
+ value = build()
35
+ tmp = path.with_suffix(".tmp")
36
+ tmp.write_text(json.dumps(value))
37
+ tmp.replace(path)
38
+ return value