This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new aec3833  Add a template checker script and include it in lints
aec3833 is described below

commit aec383333ec532197b23931b27830f1d9bb6ae69
Author: Dave Fisher <[email protected]>
AuthorDate: Sun Feb 8 03:24:03 2026 -0800

    Add a template checker script and include it in lints
    
    * Check templates linter
    
    * Preform checks and add to pre-commit-config
---
 .pre-commit-config.yaml    |   7 ++
 scripts/check_templates.py | 255 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 262 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8189471..8dc9134 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -195,3 +195,10 @@ repos:
       language: system
       pass_filenames: false
       files: ^atr/models/.*\.py$
+
+    - id: template-usage-linter
+      name: check template usage
+      description: Ensure that all templates are used and available w/o 
duplicates
+      entry: uv run --frozen python scripts/check_templates.py atr
+      language: system
+      pass_filenames: false
diff --git a/scripts/check_templates.py b/scripts/check_templates.py
new file mode 100755
index 0000000..eed9117
--- /dev/null
+++ b/scripts/check_templates.py
@@ -0,0 +1,255 @@
+#!/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.
+
+import argparse
+import ast
+import re
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+TEMPLATE_SUFFIXES = {".html", ".htm", ".j2", ".jinja"}
+JINJA_REF_RE = re.compile(
+    r"""{%\s*
+        (?:include|extends|import|from)
+        \s+["']([^"']+)["']
+        """,
+    re.VERBOSE,
+)
+
+
+class TemplateVisitor(ast.NodeVisitor):
+    def __init__(self, filename):
+        self.filename = filename
+        self.found = []  # (template_name, lineno)
+
+    def visit_Call(self, node):
+        if isinstance(node.func, ast.Attribute) and node.func.attr == "render":
+            if node.args:
+                arg = node.args[0]
+                if isinstance(arg, ast.Constant) and isinstance(arg.value, 
str):
+                    self.found.append((arg.value, node.lineno))
+        self.generic_visit(node)
+
+
+def detect_cycles(graph):
+    cycles = []
+    visiting = set()
+    visited = set()
+
+    def visit(node, stack):
+        if node in visiting:
+            cycle = [*stack[stack.index(node) :], node]
+            cycles.append(cycle)
+            return
+        if node in visited:
+            return
+
+        visiting.add(node)
+        for nxt in graph.get(node, ()):
+            visit(nxt, [*stack, nxt])
+        visiting.remove(node)
+        visited.add(node)
+
+    for node in graph:
+        visit(node, [node])
+
+    return cycles
+
+
+def find_template_references(locations):
+    """
+    locations: {template_name: [Path, ...]}
+    Returns: {template_name: set(referenced_template_names)}
+    """
+    refs = defaultdict(set)
+
+    missing_includes = defaultdict(set)
+
+    known = set(locations.keys())
+
+    for name, paths in locations.items():
+        for path in paths:
+            try:
+                text = path.read_text(encoding="utf-8")
+            except OSError:
+                continue
+
+            for match in JINJA_REF_RE.findall(text):
+                ref = Path(match).name
+                refs[name].add(ref)
+                if ref not in known:
+                    missing_includes[name].add(ref)
+
+    return refs, missing_includes
+
+
+def find_templates_in_code(source_root: Path):
+    used = set()
+    origins = defaultdict(list)
+
+    for pyfile in source_root.rglob("*.py"):
+        try:
+            tree = ast.parse(pyfile.read_text(encoding="utf-8"))
+        except SyntaxError:
+            continue
+
+        visitor = TemplateVisitor(pyfile)
+        visitor.visit(tree)
+
+        for name, lineno in visitor.found:
+            filename_only = Path(name).name
+            used.add(filename_only)
+            origins[filename_only].append((pyfile, lineno))
+
+    used.add("blank.html")
+    origins["blank.html"].append(("atr/template.py", 42))
+
+    return used, origins
+
+
+def find_template_dirs(source_root: Path):
+    return [p for p in source_root.rglob("templates") if p.is_dir()]
+
+
+def find_templates_on_disk(source_root: Path):
+    present = set()
+    locations = defaultdict(list)
+
+    for tdir in find_template_dirs(source_root):
+        for p in tdir.rglob("*"):
+            if p.suffix in TEMPLATE_SUFFIXES:
+                name = p.name  # filename-only
+                present.add(name)
+                locations[name].append(p)
+
+    return present, locations
+
+
+def resolve_used_templates(python_used, template_refs):
+    reachable = set()
+    stack = list(python_used)
+
+    while stack:
+        current = stack.pop()
+        if current in reachable:
+            continue
+
+        reachable.add(current)
+        for ref in template_refs.get(current, ()):
+            if ref not in reachable:
+                stack.append(ref)
+
+    return reachable
+
+
+def reverse_refs(refs):
+    rev = defaultdict(set)
+    for src, targets in refs.items():
+        for t in targets:
+            rev[t].add(src)
+    return rev
+
+
+def print_map(reachable, origins, rev_refs, refs):
+    print("\n== Template usage map ==")
+    for name in sorted(reachable):
+        print(f"\n{name}")
+
+        if name in origins:
+            for file, line in origins[name]:
+                print(f"  rendered from {file}:{line}")
+
+        for parent in sorted(rev_refs.get(name, [])):
+            print(f"  included by {parent}")
+
+        for child in sorted(refs.get(name, [])):
+            print(f"  includes {child}")
+
+
+def main():  # noqa: C901
+    parser = argparse.ArgumentParser()
+    parser.add_argument("source_root", help="Source tree root")
+    parser.add_argument(
+        "--usage-map",
+        action="store_true",
+        help="Display template usage map",
+    )
+    args = parser.parse_args()
+
+    root = Path(args.source_root)
+
+    used, origins = find_templates_in_code(root)
+    present, locations = find_templates_on_disk(root)
+    refs, missing_includes = find_template_references(locations)
+    reachable = resolve_used_templates(used, refs)
+    rev_refs = reverse_refs(refs)
+
+    missing = used - present
+
+    duplicates = {k: v for k, v in locations.items() if len(v) > 1}
+    unreachable = present - reachable
+    cycles = detect_cycles(refs)
+
+    if args.usage_map:
+        print_map(reachable, origins, rev_refs, refs)
+
+    errors = False
+
+    if missing:
+        errors = True
+        print("\nMissing templates")
+        for t in sorted(missing):
+            print(f"  {t}")
+            for file, line in origins.get(t, []):
+                print(f"      referenced at {file}:{line}")
+
+    if missing_includes:
+        errors = True
+        print("\nMissing included templates")
+        for src, refs in missing_includes.items():
+            for ref in refs:
+                print(f"  {src} includes missing {ref}")
+
+    if duplicates:
+        errors = True
+        print("\nDuplicate template definitions")
+        for name, paths in duplicates.items():
+            print(f"  {name}")
+            for p in paths:
+                print(f"      {p}")
+
+    if unreachable:
+        errors = True
+        print("\nUnreachable templates")
+        for t in sorted(unreachable):
+            print(f"  {t}")
+            for p in locations[t]:
+                print(f"      {p}")
+
+    if cycles:
+        errors = True
+        print("\nTemplate includes cycles")
+        for cycle in cycles:
+            print("  " + " → ".join(cycle))
+
+    sys.exit(1 if errors else 0)
+
+
+if __name__ == "__main__":
+    main()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to