初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import json
|
||||
import lzma
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tarfile
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
PACKAGE_NAME = re.compile(r"^([a-z0-9][a-z0-9+.-]*(?::(?:any|native|arm64))?)")
|
||||
ARCH_FILTER = re.compile(r"\[([^]]+)]")
|
||||
VERSION_FILTER = re.compile(r"\((=|>=|<=|>>|<<)\s*([^)\s]+)\)")
|
||||
IMAGE_DEBIAN_ROOTS = ("ffmpeg", "libheif-examples", "python3.11-venv")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dependency:
|
||||
name: str
|
||||
operator: str = ""
|
||||
version: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageRecord:
|
||||
name: str
|
||||
version: str
|
||||
architecture: str
|
||||
dependencies: tuple[tuple[Dependency, ...], ...]
|
||||
provides: tuple[str, ...]
|
||||
filename: str = ""
|
||||
sha256: str = ""
|
||||
size: int = 0
|
||||
repository_base: str = ""
|
||||
local_path: Path | None = None
|
||||
|
||||
|
||||
def normalize_package_name(value: str) -> str:
|
||||
value = value.strip()
|
||||
if ":" in value:
|
||||
name, qualifier = value.rsplit(":", 1)
|
||||
if qualifier in {"any", "native", "arm64"}:
|
||||
return name
|
||||
return value
|
||||
|
||||
|
||||
def _arch_applies(value: str, architecture: str = "arm64") -> bool:
|
||||
match = ARCH_FILTER.search(value)
|
||||
if not match:
|
||||
return True
|
||||
filters = match.group(1).split()
|
||||
positives = {item for item in filters if not item.startswith("!")}
|
||||
negatives = {item[1:] for item in filters if item.startswith("!")}
|
||||
return architecture not in negatives and (not positives or architecture in positives)
|
||||
|
||||
|
||||
def parse_dependency_field(value: str) -> tuple[tuple[Dependency, ...], ...]:
|
||||
groups: list[tuple[Dependency, ...]] = []
|
||||
for raw_group in value.split(","):
|
||||
alternatives: list[Dependency] = []
|
||||
for raw_alternative in raw_group.split("|"):
|
||||
if not _arch_applies(raw_alternative):
|
||||
continue
|
||||
match = PACKAGE_NAME.match(raw_alternative.strip())
|
||||
if not match:
|
||||
continue
|
||||
version_match = VERSION_FILTER.search(raw_alternative)
|
||||
alternatives.append(
|
||||
Dependency(
|
||||
normalize_package_name(match.group(1)),
|
||||
version_match.group(1) if version_match else "",
|
||||
version_match.group(2) if version_match else "",
|
||||
)
|
||||
)
|
||||
if alternatives:
|
||||
groups.append(tuple(alternatives))
|
||||
return tuple(groups)
|
||||
|
||||
|
||||
def parse_control_stanzas(text: str) -> list[dict[str, str]]:
|
||||
stanzas: list[dict[str, str]] = []
|
||||
current: dict[str, str] = {}
|
||||
current_key = ""
|
||||
for line in text.splitlines() + [""]:
|
||||
if not line:
|
||||
if current:
|
||||
stanzas.append(current)
|
||||
current = {}
|
||||
current_key = ""
|
||||
continue
|
||||
if line[0].isspace() and current_key:
|
||||
current[current_key] += " " + line.strip()
|
||||
continue
|
||||
key, separator, value = line.partition(":")
|
||||
if separator:
|
||||
current_key = key
|
||||
current[key] = value.strip()
|
||||
return stanzas
|
||||
|
||||
|
||||
def record_from_fields(
|
||||
fields: dict[str, str], *, repository_base: str = "", local_path: Path | None = None
|
||||
) -> PackageRecord:
|
||||
dependencies = ", ".join(
|
||||
value for key in ("Pre-Depends", "Depends") if (value := fields.get(key, ""))
|
||||
)
|
||||
provides = tuple(
|
||||
normalize_package_name(item.strip().split()[0])
|
||||
for item in fields.get("Provides", "").split(",")
|
||||
if item.strip()
|
||||
)
|
||||
return PackageRecord(
|
||||
name=normalize_package_name(fields["Package"]),
|
||||
version=fields["Version"],
|
||||
architecture=fields.get("Architecture", "arm64"),
|
||||
dependencies=parse_dependency_field(dependencies),
|
||||
provides=provides,
|
||||
filename=fields.get("Filename", ""),
|
||||
sha256=fields.get("SHA256", "").lower(),
|
||||
size=int(fields.get("Size", "0")),
|
||||
repository_base=repository_base,
|
||||
local_path=local_path,
|
||||
)
|
||||
|
||||
|
||||
def load_package_index(path: Path, repository_base: str) -> dict[str, PackageRecord]:
|
||||
path = Path(path)
|
||||
if path.suffix == ".xz":
|
||||
with lzma.open(path, "rt", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
else:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
records: dict[str, PackageRecord] = {}
|
||||
for fields in parse_control_stanzas(text):
|
||||
if fields.get("Architecture") not in {"arm64", "all"}:
|
||||
continue
|
||||
record = record_from_fields(fields, repository_base=repository_base)
|
||||
records.setdefault(record.name, record)
|
||||
return records
|
||||
|
||||
|
||||
def _read_ar_member(path: Path, wanted_prefix: str) -> bytes:
|
||||
data = Path(path).read_bytes()
|
||||
if not data.startswith(b"!<arch>\n"):
|
||||
raise ValueError(f"not a Debian ar archive: {path}")
|
||||
offset = 8
|
||||
while offset + 60 <= len(data):
|
||||
header = data[offset : offset + 60]
|
||||
name = header[:16].decode("ascii").strip().rstrip("/")
|
||||
size = int(header[48:58].decode("ascii").strip())
|
||||
start = offset + 60
|
||||
end = start + size
|
||||
if name.startswith(wanted_prefix):
|
||||
return data[start:end]
|
||||
offset = end + (size % 2)
|
||||
raise ValueError(f"{wanted_prefix} is missing from {path}")
|
||||
|
||||
|
||||
def read_deb_control(path: Path) -> dict[str, str]:
|
||||
control_archive = _read_ar_member(path, "control.tar")
|
||||
with tarfile.open(fileobj=BytesIO(control_archive), mode="r:*") as archive:
|
||||
member = next(
|
||||
(item for item in archive.getmembers() if item.name.lstrip("./") == "control"),
|
||||
None,
|
||||
)
|
||||
if member is None:
|
||||
raise ValueError(f"control file is missing from {path}")
|
||||
handle = archive.extractfile(member)
|
||||
if handle is None:
|
||||
raise ValueError(f"control file cannot be read from {path}")
|
||||
stanzas = parse_control_stanzas(handle.read().decode("utf-8"))
|
||||
if len(stanzas) != 1:
|
||||
raise ValueError(f"unexpected control data in {path}")
|
||||
return stanzas[0]
|
||||
|
||||
|
||||
def load_local_packages(directory: Path) -> dict[str, PackageRecord]:
|
||||
records: dict[str, PackageRecord] = {}
|
||||
for path in sorted(Path(directory).glob("*.deb")):
|
||||
record = record_from_fields(read_deb_control(path), local_path=path.resolve())
|
||||
if record.name in records:
|
||||
raise ValueError(f"duplicate package {record.name} in {directory}")
|
||||
records[record.name] = record
|
||||
return records
|
||||
|
||||
|
||||
def load_installed_inventory(path: Path) -> dict[str, str]:
|
||||
installed: dict[str, str] = {}
|
||||
for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"invalid inventory line {number}")
|
||||
status, name, version = parts
|
||||
if status == "ii ":
|
||||
installed[normalize_package_name(name)] = version
|
||||
return installed
|
||||
|
||||
|
||||
def _exact_version_matches(dependency: Dependency, version: str) -> bool:
|
||||
return dependency.operator != "=" or dependency.version == version
|
||||
|
||||
|
||||
def resolve_closure(
|
||||
roots: list[str],
|
||||
installed: dict[str, str],
|
||||
local: dict[str, PackageRecord],
|
||||
available: dict[str, PackageRecord],
|
||||
) -> tuple[dict[str, PackageRecord], list[str]]:
|
||||
installed = dict(installed)
|
||||
for name in local:
|
||||
installed.pop(name, None)
|
||||
providers: dict[str, list[PackageRecord]] = {}
|
||||
for record in [*local.values(), *available.values()]:
|
||||
for provided in record.provides:
|
||||
providers.setdefault(provided, []).append(record)
|
||||
selected: dict[str, PackageRecord] = {}
|
||||
queue: deque[str] = deque(normalize_package_name(item) for item in roots)
|
||||
unresolved: list[str] = []
|
||||
|
||||
def candidate_for(dependency: Dependency) -> PackageRecord | None:
|
||||
direct = local.get(dependency.name) or available.get(dependency.name)
|
||||
if direct and _exact_version_matches(dependency, direct.version):
|
||||
return direct
|
||||
for provider in providers.get(dependency.name, []):
|
||||
if _exact_version_matches(dependency, provider.version):
|
||||
return provider
|
||||
return None
|
||||
|
||||
while queue:
|
||||
name = queue.popleft()
|
||||
if name in selected:
|
||||
continue
|
||||
record = local.get(name) or available.get(name)
|
||||
if record is None:
|
||||
unresolved.append(f"required package is unavailable: {name}")
|
||||
continue
|
||||
selected[name] = record
|
||||
for group in record.dependencies:
|
||||
if any(
|
||||
dependency.name in installed
|
||||
and _exact_version_matches(dependency, installed[dependency.name])
|
||||
for dependency in group
|
||||
):
|
||||
continue
|
||||
if any(
|
||||
dependency.name in selected
|
||||
and _exact_version_matches(dependency, selected[dependency.name].version)
|
||||
for dependency in group
|
||||
):
|
||||
continue
|
||||
choice = next((candidate_for(dependency) for dependency in group if candidate_for(dependency)), None)
|
||||
if choice is None:
|
||||
unresolved.append(
|
||||
f"{record.name} cannot satisfy: "
|
||||
+ " | ".join(
|
||||
f"{item.name} {item.operator} {item.version}".strip() for item in group
|
||||
)
|
||||
)
|
||||
continue
|
||||
queue.append(choice.name)
|
||||
return selected, sorted(set(unresolved))
|
||||
|
||||
|
||||
def verify_local_closure(directory: Path, inventory: Path, roots: list[str]) -> dict[str, PackageRecord]:
|
||||
local = load_local_packages(directory)
|
||||
selected, unresolved = resolve_closure(roots, load_installed_inventory(inventory), local, {})
|
||||
missing = sorted(set(roots) - set(selected))
|
||||
if missing or unresolved:
|
||||
detail = "; ".join([*(f"missing root: {item}" for item in missing), *unresolved])
|
||||
raise ValueError(f"offline Debian dependency closure is incomplete: {detail}")
|
||||
return selected
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_records(records: dict[str, PackageRecord], output: Path) -> list[Path]:
|
||||
output = Path(output)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
downloaded: list[Path] = []
|
||||
for record in sorted(records.values(), key=lambda item: item.name):
|
||||
if record.local_path is not None:
|
||||
continue
|
||||
if not record.filename or not record.sha256 or not record.size:
|
||||
raise ValueError(f"repository metadata is incomplete for {record.name}")
|
||||
target = output / Path(record.filename).name
|
||||
if target.exists():
|
||||
if target.stat().st_size != record.size or _sha256_file(target) != record.sha256:
|
||||
raise ValueError(f"existing download does not match repository metadata: {target}")
|
||||
downloaded.append(target)
|
||||
continue
|
||||
url = urljoin(record.repository_base.rstrip("/") + "/", record.filename)
|
||||
partial = target.with_suffix(target.suffix + ".partial")
|
||||
with urlopen(url, timeout=60) as response, partial.open("wb") as handle:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
handle.write(chunk)
|
||||
if partial.stat().st_size != record.size or _sha256_file(partial) != record.sha256:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise ValueError(f"download verification failed: {record.name}")
|
||||
partial.replace(target)
|
||||
downloaded.append(target)
|
||||
return downloaded
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Resolve a minimal Debian 12/AArch64 offline closure")
|
||||
parser.add_argument("--inventory", type=Path, required=True)
|
||||
parser.add_argument("--local", type=Path, required=True)
|
||||
parser.add_argument("--index", action="append", default=[], metavar="PATH=REPOSITORY_BASE")
|
||||
parser.add_argument("--root", action="append", required=True)
|
||||
parser.add_argument("--download-to", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
available: dict[str, PackageRecord] = {}
|
||||
index_info: list[dict[str, str]] = []
|
||||
for value in args.index:
|
||||
path_text, separator, repository_base = value.partition("=")
|
||||
if not separator:
|
||||
parser.error("--index must be PATH=REPOSITORY_BASE")
|
||||
path = Path(path_text).resolve()
|
||||
for name, record in load_package_index(path, repository_base).items():
|
||||
available.setdefault(name, record)
|
||||
index_info.append({"file": path.name, "sha256": _sha256_file(path), "base": repository_base})
|
||||
|
||||
local = load_local_packages(args.local)
|
||||
selected, unresolved = resolve_closure(
|
||||
args.root, load_installed_inventory(args.inventory), local, available
|
||||
)
|
||||
if unresolved:
|
||||
raise SystemExit("\n".join(unresolved))
|
||||
downloaded = download_records(selected, args.download_to) if args.download_to else []
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"architecture": "arm64",
|
||||
"roots": args.root,
|
||||
"inventory_sha256": _sha256_file(args.inventory),
|
||||
"indices": index_info,
|
||||
"selected": [
|
||||
{
|
||||
"package": item.name,
|
||||
"version": item.version,
|
||||
"source": "local" if item.local_path else item.filename,
|
||||
"sha256": _sha256_file(item.local_path) if item.local_path else item.sha256,
|
||||
"size": item.local_path.stat().st_size if item.local_path else item.size,
|
||||
}
|
||||
for item in sorted(selected.values(), key=lambda record: record.name)
|
||||
],
|
||||
"downloaded_files": [item.name for item in downloaded],
|
||||
}
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.report:
|
||||
args.report.write_text(rendered, encoding="utf-8")
|
||||
else:
|
||||
print(rendered, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user