blob: 34321015eca832b91e2108d0cd4bd8c1165f58fb [file]
#!/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 collections
import pathlib
import re
import sys
from typing import Final
JINJA_REF_RE: Final = re.compile(
r"""{%[-+]?\s*
(?:include|extends|import|from)
\s+["']([^"']+)["']
""",
re.VERBOSE,
)
TEMPLATE_SUFFIXES: Final = {".html", ".htm", ".j2", ".jinja"}
class TemplateVisitor(ast.NodeVisitor):
def __init__(self, filename):
self.filename = filename
# (template_name, lineno)
self.found = []
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) :]
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 = collections.defaultdict(set)
missing_includes = collections.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 = pathlib.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: pathlib.Path):
used = set()
origins = collections.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 = pathlib.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: pathlib.Path):
return [p for p in source_root.rglob("templates") if p.is_dir()]
def find_templates_on_disk(source_root: pathlib.Path):
present = set()
locations = collections.defaultdict(list)
for tdir in find_template_dirs(source_root):
for p in tdir.rglob("*"):
if p.suffix in TEMPLATE_SUFFIXES:
# filename-only
name = p.name
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 = collections.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 = pathlib.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, missing_refs in missing_includes.items():
for ref in missing_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()