Validates each source by its kind, to match what the build accepts

This commit is contained in:
Alicia Sykes committed 2026-09-25 20:00:01 +01:00
1 parent 067e454fbc
commit ffc252ed99
6 files changed
+55 -80

No files matched your search

+10 -13
View File
@@ -134,29 +134,26 @@ jobs:
- name: Validate sources.csv + overrides.json
run: python lib/validate_sources.py sources.csv overrides.json
# Fetch any source URL this PR adds and check it the way the build will,
# so a broken template is caught here rather than in the nightly build
# Check source urls this PR changes: a collection the build can't read stops the nightly
- name: Validate newly added sources
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
# -d '[:blank:]' strips spaces/tabs but keeps the newline between urls
added=$(git diff "$BASE_SHA" -- sources.csv \
| grep '^+[^+]' \
| cut -d, -f2 \
| tr -d '[:blank:]' \
| grep -E '^https?://' || true)
if [ -z "$added" ]; then
echo "No new sources in this PR."
# '|| true' since grep exits 1 when this PR only touches overrides.json
urls=$(git diff "$BASE_SHA" -- sources.csv \
| grep '^+[^+]' | cut -c2- \
| awk -F, '{ gsub(/[[:blank:]]/, "", $2)
if ($2 ~ /^https?:\/\//) print $2 }' || true)
if [ -z "$urls" ]; then
echo "This PR changes no source urls, nothing to fetch."
exit 0
fi
status=0
for url in $added; do
echo "::group::$url"
for url in $urls; do
echo "--- Validating $url"
python lib/validate_sources.py "$url" || status=1
echo "::endgroup::"
done
exit $status
+4 -10
View File
@@ -56,9 +56,7 @@ def template_rank(t, pinned=None):
return (t['_source'] == pinned, -t['_priority'], template_score(t))
def source_priority(sources):
"""File name -> dedup priority, where lower wins. An app source outranks every
collection, because its author maintains that one app and knows it best.
Within each kind, the order they're listed in sources.csv decides."""
"""File name -> dedup priority, lower wins: app sources first, then sources.csv order"""
ordered = [s for s in sources if s.is_app] + [s for s in sources if not s.is_app]
return {s.filename: i for i, s in enumerate(ordered, start=1)}
@@ -248,9 +246,7 @@ def normalize_template_fields(templates):
return normalized
def schema_errors(t):
"""Schema violations in a single template. Checked per template so that one bad
entry from an upstream source gets dropped, rather than failing the whole build
at the final validation."""
"""Schema violations in one template, so a bad entry is dropped instead of failing the build"""
probe = {k: v for k, v in t.items() if not k.startswith('_')}
probe.setdefault('id', 1) # combine assigns real ids at write time
return [e.message for e in ITEM_VALIDATOR.iter_errors(probe)]
@@ -392,9 +388,7 @@ def audit_overrides(templates, overrides):
log.warning(f'Redundant override: normal priority already picks {prefer} for {where}')
def audit_app_sources(templates, sources):
"""An author who publishes their own template, but whose app also still sits in
sources/local/, never gets their updates published: local wins every time.
Warn, so the stale local copy can be deleted."""
"""Warn when a local copy shadows an app source, since the author's updates never publish"""
local = {}
for t in templates:
if t['_priority'] == 0 and isinstance(t.get('title'), str):
@@ -449,7 +443,7 @@ if __name__ == '__main__':
previous = len(json.load(f)['templates'])
except (OSError, ValueError, KeyError):
previous = 0
# An app source going away costs only its own app, so it just gets left out
# A missing app source only costs its own app, so it's left out rather than fatal
unavailable = missing_sources(sources, sources_list.APP)
if unavailable:
log.warning(f'{len(unavailable)} app sources were not downloaded, so they are left '
+1 -3
View File
@@ -54,9 +54,7 @@ failures = [source for source in sources if not download(source)]
log.info(f'Downloaded {len(sources) - len(failures)}/{len(sources)} sources')
# A collection carries hundreds of apps, so losing one is worth stopping for.
# An app source costs only its own app, and there are far more of them to go
# stale, so the build goes on without it.
# A lost collection stops the build; a lost app source only costs its own app
failed_apps = [source.name for source in failures if source.is_app]
failed_collections = [source.name for source in failures if not source.is_app]
if failed_apps:
+2 -4
View File
@@ -47,8 +47,7 @@ def generate_app_list():
return markdown_content
def github_user(*urls):
"""The GitHub account behind a source, for its avatar and credit link. Falls back to
the maintainer link, since a source's JSON may be served from anywhere."""
"""The GitHub account behind a source, for its avatar: from its url, else its maintainer"""
for url in urls:
parsed = urllib.parse.urlparse(url or '')
path_parts = [p for p in parsed.path.split('/') if p]
@@ -66,8 +65,7 @@ def source_credit(source, label):
f'[@{username}](https://github.com/{username})')
def app_label(source):
"""An app source's own title, read back from what was downloaded, so the credit reads
"Cantinarr" rather than "cantinarr". Falls back to the source name when unfetched."""
"""An app's own title from what was downloaded, so the credit reads Cantinarr not cantinarr"""
try:
templates = load_json_file(os.path.join(external_dir, source.filename))['templates']
titles = [t['title'] for t in templates if isinstance(t, dict) and t.get('title')]
+8 -28
View File
@@ -1,19 +1,4 @@
"""Reading sources.csv, and fetching what it points at, in one place.
Each row is: name, url, maintainer, kind
name unique, lowercase; becomes sources/external/<name>.json
url http(s) link to the raw JSON
maintainer the source's home page, shown as its "Report issues" link
kind 'collection' for someone's maintained list of apps, 'app' for a
single app published by its own author. Blank means collection,
so the original three-column rows still read correctly.
The build trusts the two kinds differently. An app source outranks every
collection when the same app appears in both, since its author maintains that
one app and knows it best. In exchange it may only carry a handful of
templates, and if it stops resolving the build carries on without it.
"""
"""Reads sources.csv (name, url, maintainer, kind) and fetches what it points at."""
import csv
import os
import re
@@ -27,6 +12,7 @@ log = get_logger()
SOURCES_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'sources.csv')
# 'collection' is a list of many apps, 'app' is one app published by its own author
COLLECTION = 'collection'
APP = 'app'
KINDS = (COLLECTION, APP)
@@ -34,8 +20,7 @@ KINDS = (COLLECTION, APP)
# A source name becomes a file name, so keep it to something unambiguous
NAME_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*$')
# An app may reasonably ship a container and a stack, or a plain build and a
# GPU one. Much past that is a collection, and belongs in one.
# Enough for a container plus a stack, or a plain plus a GPU build; more is a collection
MAX_APP_TEMPLATES = 5
class Source(NamedTuple):
@@ -63,8 +48,7 @@ def rows(path=SOURCES_CSV):
yield line, cells
def row_problems(cells):
"""Everything wrong with a row, as (level, message) pairs. Any problem at all
means load() skips the row; the level only decides how loudly to report it."""
"""Everything wrong with a row, as (level, message) pairs; any problem makes load() skip it"""
if len(cells) < 2 or not cells[0] or not cells[1]:
return [('warning', f'malformed row, would be skipped: {cells}')]
problems = []
@@ -89,8 +73,7 @@ def parse(cells):
return Source(cells[0], cells[1], maintainer, kind)
def load(path=SOURCES_CSV):
"""Every usable source, in listed order. Rows with problems are skipped here
and reported by validate_sources.py, which is what gates a PR."""
"""Every usable source, in listed order. validate_sources.py reports the rows skipped here"""
sources, seen = [], set()
for line, cells in rows(path):
problems = row_problems(cells)
@@ -106,9 +89,8 @@ def load(path=SOURCES_CSV):
return sources
def fetch_json(url, attempts=3):
"""Fetch and parse JSON from a url, retrying a couple of times on a blip.
Returns (payload, error message), of which exactly one is set."""
import requests # only the fetching path needs it, so keep it off the parsers
"""Fetch and parse JSON, retrying on a blip. Returns (payload, error), one of them None"""
import requests # only the fetching path needs it
for attempt in range(attempts):
try:
response = requests.get(url, timeout=30)
@@ -120,9 +102,7 @@ def fetch_json(url, attempts=3):
time.sleep(2 ** attempt)
def templates_in(payload, allow_bare=False):
"""The template list inside a downloaded source, whatever shape it arrived in,
or None if there isn't one. `allow_bare` accepts a single unwrapped template
object, which is the natural way to publish just the one app."""
"""The template list in a downloaded source, or None. `allow_bare` takes a lone object"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
+30 -22
View File
@@ -45,7 +45,7 @@ def quality_warnings(where, template):
def check_templates(name, templates):
"""Validate a list of templates: each must normalize to a schema-valid entry"""
errors, warnings, titles = [], [], Counter()
errors, warnings, seen = [], [], Counter()
if not templates:
warnings.append(f'{name}: contains no templates')
for index, raw in enumerate(templates):
@@ -55,7 +55,6 @@ def check_templates(name, templates):
continue
title = raw.get('title')
if isinstance(title, str) and title.strip():
titles[title.strip()] += 1
where = f'{name} "{title.strip()}"'
# Normalize a copy exactly as combine.py would, then judge the published result
try:
@@ -68,9 +67,13 @@ def check_templates(name, templates):
errors += [f'{where}: {e.message}' for e in VALIDATOR.iter_errors(template)]
errors += stackfile_errors(where, template)
warnings += quality_warnings(where, template)
# Keyed like combine's dedup, so one app may ship both a container and a stack
if isinstance(title, str) and title.strip():
seen[(normalize_string(title), template.get('type', 1))] += 1
errors += [f'{name}: duplicate title {title!r} ({count} templates)'
for title, count in titles.items() if count > 1]
errors += [f'{name}: duplicate template {title!r} of type {kind} ({count} copies), '
'so all but one would be dropped'
for (title, kind), count in seen.items() if count > 1]
return errors, warnings
def check_source(path):
@@ -86,24 +89,32 @@ def check_source(path):
return check_templates(os.path.basename(path), templates)
def url_kind(url):
"""The kind sources.csv gives this url, defaulting to app, the stricter of the two"""
for source in sources_list.load():
if source.url == url:
return source.kind
return sources_list.APP
def check_url(url):
"""Fetch a remote source and check it the way the nightly build will, so an author
can test their own template before opening a PR"""
"""Check a remote source as download.py will: an app per template, a collection on shape"""
payload, error = sources_list.fetch_json(url)
if error:
return [f'{url}: could not fetch valid JSON ({error})'], []
templates = sources_list.templates_in(payload, allow_bare=True)
if templates is None:
return [f'{url}: found no templates. Publish a single template as a JSON object, '
'or several under a top-level "templates" array'], []
errors, warnings = check_templates(url, templates)
is_app = url_kind(url) == sources_list.APP
templates = sources_list.templates_in(payload, allow_bare=is_app)
if not templates:
shape = ('one template as a JSON object, or several under a top-level "templates" '
'array') if is_app else 'its templates under a top-level "templates" array'
return [f'{url}: found no templates, so the build would skip it. Publish {shape}'], []
if not is_app:
return [], [] # the shape is all the build needs from a collection
if len(templates) > sources_list.MAX_APP_TEMPLATES:
warnings.append(f'{url}: holds {len(templates)} templates, so it can only be listed '
f'as a collection, not an app source (which may carry at most '
f'{sources_list.MAX_APP_TEMPLATES})')
return errors, warnings
return [f'{url}: holds {len(templates)} templates, so it can only be listed as a '
f'collection, not an app source (which may carry at most '
f'{sources_list.MAX_APP_TEMPLATES})'], []
return check_templates(url, templates)
def check_stack(path):
"""A compose stack file must be valid YAML with a non-empty services mapping"""
@@ -122,16 +133,14 @@ def check_stack(path):
return [], warnings
def local_source_names():
"""File names (minus .json) in sources/local/. A source in sources.csv must not reuse
one, since combine.py tells sources apart by file name alone"""
"""File names in sources/local/, which a source name can't reuse: combine keys on them"""
local_dir = os.path.join(ROOT, 'sources', 'local')
if not os.path.isdir(local_dir):
return set()
return {os.path.splitext(f)[0] for f in os.listdir(local_dir) if f.endswith('.json')}
def check_csv(path):
"""Validate sources.csv: each row needs a unique, file-name-safe name, an http(s)
url, and a recognized kind"""
"""Validate sources.csv: unique file-name-safe name, http(s) url, recognized kind"""
try:
rows = list(sources_list.rows(path))
except OSError as err:
@@ -207,8 +216,7 @@ def is_url(target):
return target.startswith(('http://', 'https://'))
def validate_path(path):
"""Dispatch a target to the right checker: remote urls first, then by file name,
then by extension"""
"""Dispatch a target to the right checker, by url then file name then extension"""
if is_url(path):
return check_url(path)
if os.path.basename(path) == 'overrides.json':