1#!/usr/bin/env python3 2# Copyright (C) 2023 The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15import argparse 16import glob 17import os 18import subprocess 19from pathlib import Path 20import textwrap 21from typing import Generator, Iterator 22 23from util import get_out_dir 24from util import get_top_dir 25 26 27def is_git_repo(p: Path) -> bool: 28 """checks if p is in a directory that's under git version control""" 29 git = subprocess.run( 30 args=f"git remote".split(), 31 cwd=p, 32 stdout=subprocess.DEVNULL, 33 stderr=subprocess.DEVNULL, 34 ) 35 return git.returncode == 0 36 37 38def confirm(root_dir: Path, *globs: str): 39 for g in globs: 40 disallowed = g.startswith("!") 41 if disallowed: 42 g = g[1:] 43 paths = glob.iglob(g, root_dir=root_dir, recursive=True) 44 path = next(paths, None) 45 if disallowed: 46 if path is not None: 47 raise RuntimeError(f"{root_dir}/{path} unexpected") 48 else: 49 if path is None: 50 raise RuntimeError(f"{root_dir}/{g} doesn't match") 51 52 53def _should_visit(c: os.DirEntry) -> bool: 54 return c.is_dir() and not ( 55 c.is_symlink() 56 or "." in c.name 57 or "test" in c.name 58 or Path(c.path) == get_out_dir() 59 ) 60 61 62def find_matches(root_dir: Path, *globs: str) -> Generator[Path, None, None]: 63 """ 64 Finds sub-paths satisfying the patterns 65 :param root_dir the first directory to start searching from 66 :param globs glob patterns to require or disallow (if starting with "!") 67 :returns dirs satisfying the glbos 68 """ 69 bfs: list[Path] = [root_dir] 70 while len(bfs) > 0: 71 first = bfs.pop(0) 72 if is_git_repo(first): 73 try: 74 confirm(first, *globs) 75 yield first 76 except RuntimeError: 77 pass 78 children = [Path(c.path) for c in os.scandir(first) if _should_visit(c)] 79 children.sort() 80 bfs.extend(children) 81 82 83def main(): 84 p = argparse.ArgumentParser( 85 formatter_class=argparse.RawTextHelpFormatter, 86 description=textwrap.dedent( 87 f"""\ 88 A utility to find a directory that have descendants that satisfy 89 specified required glob patterns and have no descendent that 90 contradict any specified disallowed glob pattern. 91 92 Example: 93 {Path(__file__).name} '**/Android.bp' 94 {Path(__file__).name} '!**/BUILD' '**/Android.bp' 95 96 Don't forget to SINGLE QUOTE patterns to avoid shell glob expansion! 97 """ 98 ), 99 ) 100 p.add_argument( 101 "globs", 102 nargs="+", 103 help="""glob patterns to require or disallow(if preceded with "!")""", 104 ) 105 p.add_argument( 106 "--root_dir", 107 "-r", 108 type=lambda s: Path(s).resolve(), 109 default=get_top_dir(), 110 help=textwrap.dedent( 111 """\ 112 top dir to interpret the glob patterns relative to 113 defaults to %(default)s 114 """ 115 ), 116 ) 117 p.add_argument( 118 "--max", 119 "-m", 120 type=int, 121 default=1, 122 help=textwrap.dedent( 123 """\ 124 maximum number of matching directories to show 125 defaults to %(default)s 126 """ 127 ), 128 ) 129 options = p.parse_args() 130 results = find_matches(options.root_dir, *options.globs) 131 max: int = options.max 132 while max > 0: 133 max -= 1 134 path = next(results, None) 135 if path is None: 136 break 137 print(f"{path.relative_to(get_top_dir())}") 138 139 140if __name__ == "__main__": 141 main() 142