You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@incubator.apache.org by jm...@apache.org on 2026/08/19 05:17:59 UTC
(incubator) 03/05: validate the training resource index
This is an automated email from the ASF dual-hosted git repository.
justinmclean pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator.git
commit a8895e7cf001d99547c4f5843d5b4d5a4ade6825
Author: Justin Mclean <jm...@apache.org>
AuthorDate: Wed Aug 19 15:13:08 2026 +1000
validate the training resource index
---
build_local.sh | 4 +
build_site.sh | 4 +
tools/seealso/validate_resources.py | 167 ++++++++++++++++++++++++++++++++++++
3 files changed, 175 insertions(+)
diff --git a/build_local.sh b/build_local.sh
index 357f7746b..4ccc47bf0 100755
--- a/build_local.sh
+++ b/build_local.sh
@@ -11,6 +11,10 @@ function fatal() {
rm -rf "$WORKDIR"
mkdir -p "$WORKDIR"
+# validate the training resource index before anything else uses it
+python3 tools/seealso/validate_resources.py tools/seealso/resources.yml \
+ || fatal "resources.yml failed validation"
+
# 1) Bake the site
./bake.sh -b . "$WORKDIR" || fatal "Build failed, exiting"
diff --git a/build_site.sh b/build_site.sh
index 98c95f520..d3af8e2b1 100755
--- a/build_site.sh
+++ b/build_site.sh
@@ -11,6 +11,10 @@ function fatal() {
rm -rf $WORKDIR
mkdir -p $WORKDIR
+# validate the training resource index before anything else uses it
+python3 tools/seealso/validate_resources.py tools/seealso/resources.yml \
+ || fatal "resources.yml failed validation"
+
# now bake the site
./bake.sh -b . $WORKDIR || fatal "Build failed, exiting"
diff --git a/tools/seealso/validate_resources.py b/tools/seealso/validate_resources.py
new file mode 100644
index 000000000..0d14d06a5
--- /dev/null
+++ b/tools/seealso/validate_resources.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Validate resources.yml.
+
+Checks that every resource is well formed and that every theme and topic it
+claims is actually defined at the top of the file. An entry tagged with an
+undefined theme or topic is silently dropped from the generated browse pages,
+which is easy to introduce and hard to notice.
+
+Note that a word may legitimately be both a theme and a topic, as 'branding'
+and 'stewardship' are. That is a deliberate cross-listing, not an error. What
+matters is only that whatever an entry claims is defined somewhere.
+
+Usage:
+ python3 validate_resources.py [path/to/resources.yml] [--strict]
+
+Exit codes:
+ 0 no errors
+ 1 errors found, or warnings found when --strict is given
+ 2 the file could not be read or parsed
+"""
+
+from __future__ import annotations
+
+import argparse
+import collections
+import pathlib
+import sys
+
+try:
+ import yaml
+except ImportError:
+ sys.exit("PyYAML is required: pip install pyyaml")
+
+REQUIRED_FIELDS = ("id", "title", "type", "themes", "topics", "description", "url")
+DEFAULT_PATH = pathlib.Path(__file__).with_name("resources.yml")
+
+
+def load(path: pathlib.Path) -> dict:
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ sys.exit(f"cannot read {path}: {exc}")
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ sys.exit(f"cannot parse {path}: {exc}")
+ if not isinstance(data, dict):
+ sys.exit(f"{path}: expected a mapping at the top level")
+ for key in ("themes", "topics", "resources"):
+ if key not in data:
+ sys.exit(f"{path}: missing top-level '{key}'")
+ return data
+
+
+def check(data: dict) -> tuple[list[str], list[str]]:
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ themes = set(data["themes"])
+ topics = set(data["topics"])
+ resources = data["resources"]
+
+ if not isinstance(resources, list):
+ return ["'resources' must be a list"], []
+
+ seen_ids: collections.Counter = collections.Counter()
+ seen_urls: collections.Counter = collections.Counter()
+ used_themes: set[str] = set()
+ used_topics: set[str] = set()
+
+ for position, resource in enumerate(resources, start=1):
+ if not isinstance(resource, dict):
+ errors.append(f"resource #{position}: expected a mapping")
+ continue
+
+ name = resource.get("id") or f"#{position} ({resource.get('title', 'untitled')})"
+
+ for field in REQUIRED_FIELDS:
+ if field not in resource:
+ errors.append(f"{name}: missing '{field}'")
+ elif resource[field] in (None, "", [], {}):
+ errors.append(f"{name}: '{field}' is empty")
+
+ if "id" in resource:
+ seen_ids[resource["id"]] += 1
+
+ url = resource.get("url", "")
+ if url:
+ seen_urls[url] += 1
+ if not url.startswith(("http://", "https://")):
+ errors.append(f"{name}: url is not absolute: {url}")
+
+ for theme in resource.get("themes") or []:
+ used_themes.add(theme)
+ if theme not in themes:
+ errors.append(f"{name}: undefined theme '{theme}'")
+ for topic in resource.get("topics") or []:
+ used_topics.add(topic)
+ if topic not in topics:
+ errors.append(f"{name}: undefined topic '{topic}'")
+
+ for value, count in seen_ids.items():
+ if count > 1:
+ errors.append(f"duplicate id '{value}' used {count} times")
+ for value, count in seen_urls.items():
+ if count > 1:
+ errors.append(f"duplicate url '{value}' used {count} times")
+
+ for theme in sorted(themes - used_themes):
+ warnings.append(f"theme '{theme}' is defined but no resource uses it")
+ for topic in sorted(topics - used_topics):
+ warnings.append(f"topic '{topic}' is defined but no resource uses it")
+
+ return errors, warnings
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument(
+ "path", nargs="?", type=pathlib.Path, default=DEFAULT_PATH,
+ help="path to resources.yml (defaults to the one beside this script)",
+ )
+ parser.add_argument(
+ "--strict", action="store_true", help="treat warnings as errors",
+ )
+ args = parser.parse_args()
+
+ data = load(args.path)
+ errors, warnings = check(data)
+
+ for warning in warnings:
+ print(f"warning: {warning}")
+ for error in errors:
+ print(f"error: {error}", file=sys.stderr)
+
+ counts = (
+ f"{len(data['resources'])} resources, "
+ f"{len(data['themes'])} themes, "
+ f"{len(data['topics'])} topics"
+ )
+
+ if errors:
+ print(f"\nFAILED: {len(errors)} error(s), {len(warnings)} warning(s). {counts}")
+ return 1
+ if warnings and args.strict:
+ print(f"\nFAILED under --strict: {len(warnings)} warning(s). {counts}")
+ return 1
+ print(f"OK: {counts}, {len(warnings)} warning(s).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
---------------------------------------------------------------------
To unsubscribe, e-mail: cvs-unsubscribe@incubator.apache.org
For additional commands, e-mail: cvs-help@incubator.apache.org