diff --git a/scripts/check_new_py_files.py b/scripts/check_new_py_files.py new file mode 100644 index 00000000..ffcc9731 --- /dev/null +++ b/scripts/check_new_py_files.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed 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. + +"""Checks that newly-added Python files under src/google/adk/ have a '_' prefix. + +ADK is private-by-default: a newly-added Python file under src/google/adk/ must +have a '_'-prefixed basename. To make it public, add the symbol to the package +__init__.py / __all__ instead. See +.agents/skills/adk-style/references/visibility.md. + +Newly-added files are detected by diffing the working tree against a baseline +source tree (e.g. an origin/main checkout), so it works in a checkout that has +no local git history: + + python scripts/check_new_py_files.py --baseline-dir /path/to/origin-main + +Exit codes: 0 = ok, 1 = violation(s) found, 2 = usage/setup error. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +_PACKAGE_RELPATH = os.path.join('src', 'google', 'adk') + +_VIOLATION_LINE = "Error: New Python file '{path}' must have a '_' prefix." + +_GUIDANCE = ( + 'All new Python files in src/google/adk/ must be private by default.\n' + 'To expose a public interface, use __init__.py and list public symbols' + ' in __all__.\n' + 'See .agents/skills/adk-style/references/visibility.md for details.' +) + +# Subtrees that may exist in the working tree but are intentionally absent from +# the baseline tree; ignore them so the diff does not report them as newly +# added. +_IGNORED_PREFIXES = ( + 'src/google/adk/internal/', + 'src/google/adk/v1/', + 'src/google/adk/platform/internal/', +) + + +def find_py_files(root: str) -> set[str]: + """Returns root-relative paths of every *.py under /src/google/adk. + + Each path includes the src/google/adk/ prefix (e.g. + 'src/google/adk/agents/foo.py'). Symlinks are followed so that a src/google/adk + tree assembled from symlinked subdirectories is walked correctly. + """ + package_root = os.path.join(root, _PACKAGE_RELPATH) + found: set[str] = set() + for dirpath, _, filenames in os.walk(package_root, followlinks=True): + for name in filenames: + if name.endswith('.py'): + abs_path = os.path.join(dirpath, name) + found.add(os.path.relpath(abs_path, root)) + return found + + +def _should_check(relpath: str) -> bool: + """Returns False for paths under an ignored prefix.""" + return not any(relpath.startswith(prefix) for prefix in _IGNORED_PREFIXES) + + +def added_py_files(new_root: str, baseline_root: str) -> set[str]: + """Returns .py files present in new_root but not in baseline_root. + + Paths under _IGNORED_PREFIXES are skipped: they may exist in the working tree + but are intentionally absent from the baseline, so a plain diff would + otherwise report them as newly added. + """ + added = find_py_files(new_root) - find_py_files(baseline_root) + return {path for path in added if _should_check(path)} + + +def find_violations(added: set[str]) -> list[str]: + """Returns the sorted added files whose basename does not start with '_'.""" + return sorted( + path for path in added if not os.path.basename(path).startswith('_') + ) + + +def _has_package_dir(root: str) -> bool: + return os.path.isdir(os.path.join(root, _PACKAGE_RELPATH)) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--baseline-dir', + required=True, + help='Baseline source tree to diff against (an origin/main checkout).', + ) + parser.add_argument( + '--new-dir', + default='.', + help='New source tree to check (default: current directory).', + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = _parse_args(argv) + for label, root in (('baseline', args.baseline_dir), ('new', args.new_dir)): + if not _has_package_dir(root): + print( + f'Error: {label} tree has no {_PACKAGE_RELPATH} directory: {root}', + file=sys.stderr, + ) + return 2 + + violations = find_violations(added_py_files(args.new_dir, args.baseline_dir)) + for path in violations: + print(_VIOLATION_LINE.format(path=path), file=sys.stderr) + if violations: + print(_GUIDANCE, file=sys.stderr) + return 1 if violations else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))