mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* fix(sloppak): the full mix is a stem — drop the invented `original_audio` key (#933) Core read, served, and depended on `original_audio:` — a top-level manifest key this repo invented in #583 that the feedpak spec never defined. The format already had a home for the pre-separation mixdown: it is a stem. feedpak 1.15.0 (feedpak-spec#53) RESERVES the id `full` for it, so read it from there. The key existed to work around a bug in our own reader. The packer's comment said so plainly: "we must NOT list the full mix as a playable stem — the player sums every entry in `stems` and does not gate playback on `default`, so a listed full mix plays on top of the stems". Faced with a reader that would double the song, the packer put the mixdown outside `stems` and invented a key to point at it. The fix belongs in the reader, and that is what this is. load_song() now partitions the stem list: `full` comes out as LoadedSloppak.full_mix, the instruments stay in .stems. Nothing that sums stems or draws one fader per stem can see the mixdown, so retaining it is safe — which is what lets the packer put it where the format says it goes. - ws_highway: `song_info` gains full_mix_url / has_full_mix. The old original_audio_url / has_original_audio remain as deprecated aliases for one release so an older stems plugin keeps working (#945). - `stems` on the wire, and stem_ids / stem_count in the library index, are now INSTRUMENT stems only — a separated pack that retains its mixdown no longer advertises a bogus "full" chip or an inflated stem count. - enrichment: fingerprint against the mixdown wherever it lives. This widens coverage — _song_audio_file() previously returned None for any pack without the invented key, so fingerprinting silently did nothing for nearly every pack. - sloppak: `original_audio:` is still READ as a deprecated fallback, because every pack in the wild carries it and would otherwise lose its pristine mix. tools/migrate_full_mix_stem.py rewrites those packs into the spec shape (original/full.ogg -> stems/full.ogg, add the `full` stem at default:off, drop the key); the fallback and the aliases die with #945. The spec gate keeps the debt honest: the grandfather entry now tracks #945, and the gate fails if it goes stale. Verified: spec gate OK (4/4, incl. ingesting the spec's new example pack that retains `full`); 2493 python tests, 995 js tests; migrator round-tripped over real packs from the library and the results pass the spec's reference validator. * fix(migrate): discover directory-form packs instead of silently skipping them iter_packs() searched only files, so a directory-form pack (`song.sloppak/`, the authoring shape) was walked INTO and never yielded — silently missed by a run that's meant to be exhaustive. Discover suffix-named directories too (yielded whole, not descended into), and route packs through migrate_pack/verify_pack. Directory packs are REPORTED as `dir-form-unsupported`, not rewritten in place: a single-file pack is replaced atomically (a fully-built temp archive swapped in with one os.replace), but a populated directory can't be swapped that way, so an interrupted in-place rewrite could leave an authoring pack half-migrated. The status is a problem status, so it counts against the run's exit code and shows in the summary — the operator re-packs or migrates it as a `.feedpak` instead of it vanishing from the report. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> * fix(migrate): verify requires an explicit `off` on a retained full mix verify_zip accepted any non-truthy `default` on a multi-stem `full` (missing, empty, boolean, `false`/`no`/`0`, malformed) as "ok". But core defaults an ABSENT `default` to True — ON (lib/sloppak.py: `s.get("default", True)`) — and treats an empty/unrecognized string as ON too, so a migrated-shape pack whose `full` stem has a missing or blank default beside instrument stems would actually play the mixdown on open and double the song. verify was certifying that as safe. Require an explicit normalized `off` beside instrument stems: `on`-ish values are reported `full-stem-default-on` (actively plays), everything that is not a normalized `off` is reported `full-stem-default-not-off`. The migrator already writes the literal `off`, so its own output is unaffected; this also certifies the pack is in the tool's canonical, most-portable shape. The len>1 gate is kept, so a sole `full` stem (which IS the audio) is not policed. Adds parametrized coverage for missing / empty / boolean / off-ish / malformed defaults, and a sole-full-stem case. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> --------- Signed-off-by: Kris Anderson <topkoa@gmail.com> Co-authored-by: Kris Anderson <topkoa@gmail.com>
446 lines
20 KiB
Python
446 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Migrate packs off the deprecated `original_audio:` key — the full mix is a stem.
|
|
|
|
Before feedpak 1.15.0 reserved the stem id `full`, spec §5.3 said the mixdown was
|
|
"commonly replaced" by the per-instrument stems when a pack was split — so after
|
|
separation it had nowhere to live. This repo worked around that by inventing a
|
|
top-level `original_audio:` manifest key pointing at a parallel `original/`
|
|
directory (#583). The key was never in the spec, and #933 removed core's
|
|
dependence on it: the mixdown is a stem, and its id is `full`.
|
|
|
|
This rewrites a pack into the shape the spec now defines:
|
|
|
|
original/full.ogg -> stems/full.ogg (entry moved)
|
|
original_audio: original/full.ogg -> stems: [{id: full, file: stems/full.ogg,
|
|
default: 'off'}, ...]
|
|
|
|
`default: 'off'` is what makes the retained mixdown safe: a reader that honours
|
|
`default` (normative since feedpak 1.0.0) will not play it on open, so it never
|
|
doubles the mix even in a reader that predates the reserved id.
|
|
|
|
Nothing else in the pack is touched — every other key, file and stem is preserved
|
|
verbatim, and `feedpak_version` is stamped to the version the result conforms to.
|
|
|
|
The rewrite is atomic per pack: a new archive is built beside the original and
|
|
renamed over it only on success, so an interrupted run leaves every pack either
|
|
fully migrated or untouched — never truncated.
|
|
|
|
Idempotent: a pack that already carries a `full` stem and no `original_audio:` is
|
|
reported as `skip` and left alone, so a partial run can simply be re-run.
|
|
|
|
Usage:
|
|
python tools/migrate_full_mix_stem.py --dry-run /path/to/packs # report only
|
|
python tools/migrate_full_mix_stem.py /path/to/packs # migrate
|
|
python tools/migrate_full_mix_stem.py --verify /path/to/packs # check results
|
|
|
|
Exit status is 0 only when every pack ended up in the migrated shape (or was
|
|
already there).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import zipfile
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
# The version this migration brings a pack up to: the one that reserved `full`.
|
|
TARGET_FEEDPAK_VERSION = "1.15.0"
|
|
FULL_MIX_STEM_ID = "full"
|
|
LEGACY_KEY = "original_audio"
|
|
# Where the mixdown lands. §2.1's conventional layout — readers resolve through
|
|
# the manifest and never care about the path, but a pack that says `stems/` and
|
|
# means it is the one a human can read.
|
|
CANONICAL_FULL_MIX_PATH = "stems/full.ogg"
|
|
|
|
PACK_EXTS = (".feedpak", ".sloppak")
|
|
|
|
|
|
class Skip(Exception):
|
|
"""Pack needs no migration."""
|
|
|
|
|
|
def is_safe_relpath(rel: str) -> bool:
|
|
"""True when `rel` is a manifest path the spec allows (§2.2 rule 2).
|
|
|
|
POSIX-style relative: forward slashes, no leading `/`, no `..` segments, no
|
|
empty segments, no colon (which excludes drive letters and NTFS alternate
|
|
data streams), no backslashes.
|
|
|
|
This is a TRUST BOUNDARY, not a tidiness check. Core's loader refuses a
|
|
full-mix path that escapes the pack and reports the pack as having no full
|
|
mix — the audio is inert. A migration that moved such an entry into
|
|
`stems/full.ogg` would take content the reader deliberately rejected and
|
|
hand it back as a valid, playable stem. So a pack like this is reported, not
|
|
migrated.
|
|
"""
|
|
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel:
|
|
return False
|
|
parts = rel.split("/")
|
|
return all(p and p != ".." for p in parts)
|
|
|
|
|
|
def plan_manifest(manifest: dict) -> tuple[dict, str]:
|
|
"""Return (new_manifest, relpath_of_audio_to_move); "" = no file needs moving.
|
|
|
|
Raises Skip when the pack needs no migration. Pure — no I/O — so the part
|
|
with the decisions in it is testable without building archives.
|
|
"""
|
|
raw_stems = manifest.get("stems")
|
|
stems: list = raw_stems if isinstance(raw_stems, list) else []
|
|
has_full_stem = any(
|
|
isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID for s in stems
|
|
)
|
|
legacy_rel = manifest.get(LEGACY_KEY)
|
|
legacy_rel = legacy_rel.strip() if isinstance(legacy_rel, str) else ""
|
|
|
|
if not legacy_rel:
|
|
# Nothing invented to undo: either the pack already keeps its mixdown as
|
|
# the `full` stem, or it never carried one.
|
|
raise Skip("already migrated" if has_full_stem else "no original_audio key")
|
|
|
|
if has_full_stem:
|
|
# The mixdown is already a stem and the dead key merely lingers beside it.
|
|
# Drop the key; move nothing. But do NOT trust its `default`: a mixdown
|
|
# left enabled beside instrument stems is the double-audio hazard this
|
|
# migration exists to remove, and a reader that honours `default` would
|
|
# play the whole song on top of the stems on open. Force it off — unless
|
|
# `full` is the only stem, in which case it IS the audio.
|
|
others = [
|
|
s
|
|
for s in stems
|
|
if isinstance(s, dict) and str(s.get("id", "")) != FULL_MIX_STEM_ID
|
|
]
|
|
new_stems = []
|
|
for s in stems:
|
|
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID and others:
|
|
s = {**s, "default": "off"}
|
|
new_stems.append(s)
|
|
to_move = ""
|
|
else:
|
|
# `default` decides whether a reader plays this on open, and that is the
|
|
# whole safety margin: alongside per-instrument stems the mixdown must be
|
|
# OFF (a reader that sums the list would otherwise double the song), but
|
|
# when it is the pack's only stem it IS the audio and must be ON.
|
|
entry = {
|
|
"id": FULL_MIX_STEM_ID,
|
|
"file": CANONICAL_FULL_MIX_PATH,
|
|
"default": "off" if stems else "on",
|
|
}
|
|
# First in the list, matching the spec's §5.3 example.
|
|
new_stems = [entry, *stems]
|
|
# If the key already pointed at the canonical path, only the manifest is wrong.
|
|
to_move = "" if legacy_rel == CANONICAL_FULL_MIX_PATH else legacy_rel
|
|
|
|
out: dict = {}
|
|
for k, v in manifest.items():
|
|
if k == LEGACY_KEY:
|
|
continue # the invented key disappears
|
|
out[k] = new_stems if k == "stems" else v
|
|
out.setdefault("stems", new_stems) # a pack that had no stems list gets one
|
|
out["feedpak_version"] = TARGET_FEEDPAK_VERSION
|
|
return out, to_move
|
|
|
|
|
|
def migrate_zip(path: Path, dry_run: bool) -> str:
|
|
"""Rewrite one zipped pack in place. Returns a one-word status."""
|
|
with zipfile.ZipFile(path) as zf:
|
|
try:
|
|
raw = zf.read("manifest.yaml")
|
|
except KeyError:
|
|
return "no-manifest"
|
|
manifest = yaml.safe_load(raw) or {}
|
|
try:
|
|
new_manifest, old_rel = plan_manifest(manifest)
|
|
except Skip:
|
|
return "skip"
|
|
names = set(zf.namelist())
|
|
if old_rel:
|
|
if not is_safe_relpath(old_rel):
|
|
# Core refuses this path and plays no full mix for the pack. Do
|
|
# not launder it into a valid stem — see is_safe_relpath().
|
|
return "unsafe-path"
|
|
if old_rel not in names:
|
|
# The key points at audio that isn't in the archive. Core already
|
|
# treats that as "no full mix"; migrating would fabricate a stem
|
|
# entry for a file that does not exist and break every reader.
|
|
return "missing-audio"
|
|
if CANONICAL_FULL_MIX_PATH in names:
|
|
return "target-occupied"
|
|
else:
|
|
# Manifest-only rewrite (a stale key beside a mixdown that is already
|
|
# a stem, or a key that already pointed at the canonical path). Check
|
|
# the file the resulting `full` stem will actually NAME — not the
|
|
# canonical path, which an already-migrated pack is free not to use:
|
|
# §2.2 says readers resolve through the manifest, so a valid pack may
|
|
# keep its mixdown anywhere.
|
|
full_file = next(
|
|
(
|
|
s.get("file")
|
|
for s in new_manifest.get("stems", [])
|
|
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID
|
|
),
|
|
None,
|
|
)
|
|
if full_file not in names:
|
|
return "missing-audio"
|
|
if dry_run:
|
|
return "would-migrate"
|
|
|
|
# Build the replacement beside the original, on the same filesystem, so
|
|
# the final rename is atomic and an interrupted run can't truncate a pack.
|
|
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
|
)
|
|
os.close(tmp_fd)
|
|
tmp_path = Path(tmp_name)
|
|
try:
|
|
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as out:
|
|
for item in zf.infolist():
|
|
if item.filename == "manifest.yaml":
|
|
out.writestr(
|
|
item,
|
|
yaml.safe_dump(
|
|
new_manifest, sort_keys=False, allow_unicode=True
|
|
),
|
|
)
|
|
continue
|
|
data = zf.read(item.filename)
|
|
if old_rel and item.filename == old_rel:
|
|
# Same bytes, same compression, new name: the mixdown moves
|
|
# from original/ into stems/ where the format says audio goes.
|
|
moved = zipfile.ZipInfo(
|
|
CANONICAL_FULL_MIX_PATH, date_time=item.date_time
|
|
)
|
|
moved.compress_type = item.compress_type
|
|
moved.external_attr = item.external_attr
|
|
out.writestr(moved, data)
|
|
continue
|
|
out.writestr(item, data)
|
|
except BaseException:
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
shutil.copystat(path, tmp_path)
|
|
os.replace(tmp_path, path) # atomic
|
|
return "migrated"
|
|
|
|
|
|
def verify_zip(path: Path) -> str:
|
|
"""Confirm a pack is in the migrated shape and its mixdown is really there."""
|
|
with zipfile.ZipFile(path) as zf:
|
|
try:
|
|
manifest = yaml.safe_load(zf.read("manifest.yaml")) or {}
|
|
except KeyError:
|
|
return "no-manifest"
|
|
if LEGACY_KEY in manifest:
|
|
return "still-has-key"
|
|
stems = manifest.get("stems") or []
|
|
full = next(
|
|
(
|
|
s
|
|
for s in stems
|
|
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID
|
|
),
|
|
None,
|
|
)
|
|
if full is None:
|
|
return "no-full-stem"
|
|
if full.get("file") not in set(zf.namelist()):
|
|
return "full-stem-missing-file"
|
|
# A retained mixdown that plays on open would double the mix in any reader
|
|
# that sums the stem list — the whole hazard this migration must not create.
|
|
# Beside instrument stems, `full` MUST carry an explicit, normalized "off":
|
|
# - core (lib/sloppak.py) defaults an ABSENT `default` to True (ON) and
|
|
# treats an empty / unrecognized string as ON, so a missing or blank
|
|
# default is not merely non-canonical — core would play the mixdown on
|
|
# open, doubling the song. It is the exact hazard, not a lesser one.
|
|
# - the migrator always writes the literal "off", so requiring it also
|
|
# certifies the pack is in the shape this tool produces — the most
|
|
# portable spelling, understood even by a reader that only knows
|
|
# "on"/"off" and would choke on a boolean or `false`/`0`/`no`.
|
|
# So: `on`-ish values are reported as actively-playing; everything that is
|
|
# not a normalized "off" (missing, empty, boolean, `false`/`no`/`0`,
|
|
# malformed) is reported as an unsafe/non-canonical default.
|
|
if len(stems) > 1:
|
|
default = str(full.get("default", "")).strip().lower()
|
|
if default in ("true", "on", "yes", "1"):
|
|
return "full-stem-default-on"
|
|
if default != "off":
|
|
return "full-stem-default-not-off"
|
|
return "ok"
|
|
|
|
|
|
def migrate_pack(path: Path, dry_run: bool) -> str:
|
|
"""Dispatch by pack form. ZIP-file packs are rewritten in place; directory
|
|
(authoring) packs are REPORTED, not rewritten.
|
|
|
|
A single-file pack is replaced atomically — a fully-built temp archive
|
|
swapped in with one os.replace(), so an interrupted run leaves it either
|
|
fully migrated or untouched. A directory can't be swapped that way (no
|
|
atomic replace of a populated directory), so an in-place rewrite could leave
|
|
an authoring pack half-migrated. Rather than risk that, directory packs are
|
|
surfaced as `dir-form-unsupported` (a problem status, so the run's exit code
|
|
and summary flag them) for the operator to re-pack or migrate as a `.feedpak`.
|
|
"""
|
|
if path.is_dir():
|
|
return "dir-form-unsupported"
|
|
return migrate_zip(path, dry_run)
|
|
|
|
|
|
def verify_pack(path: Path) -> str:
|
|
"""Verify a pack; directory (authoring) packs are reported, see migrate_pack."""
|
|
if path.is_dir():
|
|
return "dir-form-unsupported"
|
|
return verify_zip(path)
|
|
|
|
|
|
def iter_packs(root: Path):
|
|
"""Yield every pack under `root`. A pack is a suffix-named ZIP FILE or a
|
|
suffix-named DIRECTORY (the authoring form) — both are discovered so a
|
|
directory-form pack is never silently walked past. A directory pack is
|
|
yielded whole, not descended into: its `stems/` and `arrangements/` are pack
|
|
contents, not packs. (migrate/verify then report directory packs rather than
|
|
rewriting them in place — see migrate_pack.)"""
|
|
if root.is_file():
|
|
yield root
|
|
return
|
|
# A directory whose OWN name is a pack suffix is a single directory-form
|
|
# pack passed directly, not a tree of packs to search.
|
|
if root.name.endswith(PACK_EXTS):
|
|
yield root
|
|
return
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
for fn in sorted(filenames):
|
|
if fn.endswith(PACK_EXTS):
|
|
yield Path(dirpath) / fn
|
|
for dn in sorted(dn for dn in dirnames if dn.endswith(PACK_EXTS)):
|
|
yield Path(dirpath) / dn
|
|
# Don't descend INTO a pack directory — its contents aren't packs.
|
|
dirnames[:] = [dn for dn in dirnames if not dn.endswith(PACK_EXTS)]
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("root", type=Path, help="pack, or directory of packs")
|
|
ap.add_argument("--dry-run", action="store_true", help="report, change nothing")
|
|
ap.add_argument("--verify", action="store_true", help="check the migrated shape")
|
|
ap.add_argument("--jobs", type=int, default=8, help="parallel packs (default 8)")
|
|
args = ap.parse_args(argv)
|
|
|
|
if not args.root.exists():
|
|
print(f"error: {args.root} does not exist", file=sys.stderr)
|
|
return 2
|
|
|
|
packs = list(iter_packs(args.root))
|
|
if not packs:
|
|
print(f"no packs found under {args.root}", file=sys.stderr)
|
|
return 2
|
|
|
|
action = verify_pack if args.verify else (lambda p: migrate_pack(p, args.dry_run))
|
|
|
|
def work(pack: Path) -> str:
|
|
"""Never raise. One unreadable pack must not kill a 50,000-pack run.
|
|
|
|
A library this size has damage in it — a truncated download, an archive
|
|
left half-written by an interrupted converter. Letting that propagate
|
|
aborts the whole job partway through and throws away the summary, which
|
|
is exactly when you most need to know what happened. Report it as a
|
|
problem status instead: the pack is untouched, the run continues, and the
|
|
final report names it.
|
|
"""
|
|
try:
|
|
return action(pack)
|
|
except zipfile.BadZipFile:
|
|
return "corrupt-zip"
|
|
except OSError as e:
|
|
return f"io-error ({e.__class__.__name__})"
|
|
except Exception as e: # malformed YAML, unexpected manifest shape, …
|
|
return f"error ({e.__class__.__name__})"
|
|
counts: dict[str, int] = {}
|
|
problems: list[tuple[str, Path]] = []
|
|
# A real run rewrites every archive under `root` — tens of thousands of packs
|
|
# and hundreds of gigabytes. Printing only a final summary means hours of
|
|
# silence, in which a stall and steady progress look identical. Emit a
|
|
# heartbeat instead: rate and ETA come from the packs actually finished, so
|
|
# it stays honest when the disk slows down. stderr, so `> report.txt` keeps
|
|
# the summary clean.
|
|
total = len(packs)
|
|
started = time.monotonic()
|
|
# The heartbeat runs on its OWN CLOCK, in its own thread.
|
|
#
|
|
# Two weaker designs were tried and both go quiet exactly when you need them
|
|
# to speak. Ticking every N packs ties the cadence to how slow a pack is: 500
|
|
# packs is a blink in a --dry-run and many minutes in a real migration, so the
|
|
# run that most needs watching says the least. Ticking on time but only when a
|
|
# pack *finishes* is no better: if every worker is grinding on a huge archive,
|
|
# nothing completes, so nothing prints — and a stall becomes indistinguishable
|
|
# from progress, which is the one thing a progress meter must never allow.
|
|
#
|
|
# A daemon thread on a fixed interval reports regardless. If the count stops
|
|
# advancing between beats, you are looking at a stall, and you can see it.
|
|
HEARTBEAT_SECONDS = 10.0
|
|
done = 0 # only the main loop writes it; the beat thread only reads
|
|
stop_beat = threading.Event()
|
|
|
|
def heartbeat() -> None:
|
|
while not stop_beat.wait(HEARTBEAT_SECONDS):
|
|
elapsed = time.monotonic() - started
|
|
rate = done / elapsed if elapsed > 0 else 0.0
|
|
eta = (total - done) / rate if rate > 0 else 0.0
|
|
print(
|
|
f" {done}/{total} ({100 * done / total:.1f}%) "
|
|
f"{rate:.1f} packs/s eta {eta / 60:.0f}m "
|
|
f"[{len(problems)} problem(s)]",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
|
|
print(
|
|
f"{total} pack(s) under {args.root} — "
|
|
f"{'verifying' if args.verify else 'dry run' if args.dry_run else 'migrating'} "
|
|
f"with {max(1, args.jobs)} job(s)",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
beat = threading.Thread(target=heartbeat, daemon=True)
|
|
beat.start()
|
|
|
|
# as_completed, not pool.map: map yields in SUBMISSION order, so the counter
|
|
# would stall behind one slow pack while later ones were already done — a
|
|
# progress meter that lies about progress. Count each pack as it finishes.
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
|
futures = {pool.submit(work, p): p for p in packs}
|
|
for fut in as_completed(futures):
|
|
pack = futures[fut]
|
|
status = fut.result()
|
|
counts[status] = counts.get(status, 0) + 1
|
|
if status not in ("migrated", "skip", "would-migrate", "ok"):
|
|
problems.append((status, pack))
|
|
done += 1
|
|
finally:
|
|
stop_beat.set()
|
|
beat.join(timeout=1)
|
|
|
|
print(f"\n{len(packs)} pack(s) under {args.root}")
|
|
for status, n in sorted(counts.items(), key=lambda kv: -kv[1]):
|
|
print(f" {n:>7} {status}")
|
|
if problems:
|
|
print(f"\n{len(problems)} pack(s) need a look:", file=sys.stderr)
|
|
for status, pack in problems[:20]:
|
|
print(f" {status:<22} {pack}", file=sys.stderr)
|
|
if len(problems) > 20:
|
|
print(f" … and {len(problems) - 20} more", file=sys.stderr)
|
|
return 1 if problems else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|