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.
- snap_maintainer/__init__.py +1 -0
- snap_maintainer/__main__.py +3 -0
- snap_maintainer/archive.py +143 -0
- snap_maintainer/builders.py +205 -0
- snap_maintainer/cache.py +38 -0
- snap_maintainer/checks.py +340 -0
- snap_maintainer/cli.py +197 -0
- snap_maintainer/debver.py +52 -0
- snap_maintainer/discovery.py +216 -0
- snap_maintainer/hints.py +211 -0
- snap_maintainer/manifests.py +81 -0
- snap_maintainer/mcp_server.py +275 -0
- snap_maintainer/preflight.py +102 -0
- snap_maintainer/repo.py +114 -0
- snap_maintainer/snapcraft_yaml.py +115 -0
- snap_maintainer/store.py +181 -0
- snap_maintainer/ui.py +440 -0
- snap_maintainer/usn.py +54 -0
- snap_maintainer-0.1.0.dist-info/METADATA +270 -0
- snap_maintainer-0.1.0.dist-info/RECORD +24 -0
- snap_maintainer-0.1.0.dist-info/WHEEL +5 -0
- snap_maintainer-0.1.0.dist-info/entry_points.txt +2 -0
- snap_maintainer-0.1.0.dist-info/licenses/LICENSE +21 -0
- snap_maintainer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Discover and maintain published snaps."""
|
|
@@ -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
|
snap_maintainer/cache.py
ADDED
|
@@ -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
|