xref: /llvm-project/clang/utils/analyzer/ProjectMap.py (revision dd3c26a045c081620375a878159f536758baba6e)
1import json
2import os
3
4from enum import auto, Enum
5from typing import Any, Dict, List, NamedTuple, Optional, Tuple
6
7
8JSON = Dict[str, Any]
9
10
11DEFAULT_MAP_FILE = "projects.json"
12
13
14class DownloadType(str, Enum):
15    GIT = "git"
16    ZIP = "zip"
17    SCRIPT = "script"
18
19
20class Size(int, Enum):
21    """
22    Size of the project.
23
24    Sizes do not directly correspond to the number of lines or files in the
25    project.  The key factor that is important for the developers of the
26    analyzer is the time it takes to analyze the project.  Here is how
27    the following sizes map to times:
28
29    TINY:  <1min
30    SMALL: 1min-10min
31    BIG:   10min-1h
32    HUGE:  >1h
33
34    The borders are a bit of a blur, especially because analysis time varies
35    from one machine to another.  However, the relative times will stay pretty
36    similar, and these groupings will still be helpful.
37
38    UNSPECIFIED is a very special case, which is intentionally last in the list
39    of possible sizes.  If the user wants to filter projects by one of the
40    possible sizes, we want projects with UNSPECIFIED size to be filtered out
41    for any given size.
42    """
43
44    TINY = auto()
45    SMALL = auto()
46    BIG = auto()
47    HUGE = auto()
48    UNSPECIFIED = auto()
49
50    @staticmethod
51    def from_str(raw_size: Optional[str]) -> "Size":
52        """
53        Construct a Size object from an optional string.
54
55        :param raw_size: optional string representation of the desired Size
56                         object.  None will produce UNSPECIFIED size.
57
58        This method is case-insensitive, so raw sizes 'tiny', 'TINY', and
59        'TiNy' will produce the same result.
60        """
61        if raw_size is None:
62            return Size.UNSPECIFIED
63
64        raw_size_upper = raw_size.upper()
65        # The implementation is decoupled from the actual values of the enum,
66        # so we can easily add or modify it without bothering about this
67        # function.
68        for possible_size in Size:
69            if possible_size.name == raw_size_upper:
70                return possible_size
71
72        possible_sizes = [
73            size.name.lower()
74            for size in Size
75            # no need in showing our users this size
76            if size != Size.UNSPECIFIED
77        ]
78        raise ValueError(
79            f"Incorrect project size '{raw_size}'. "
80            f"Available sizes are {possible_sizes}"
81        )
82
83
84class ProjectInfo(NamedTuple):
85    """
86    Information about a project to analyze.
87    """
88
89    name: str
90    mode: int
91    source: DownloadType = DownloadType.SCRIPT
92    origin: str = ""
93    commit: str = ""
94    enabled: bool = True
95    size: Size = Size.UNSPECIFIED
96
97    def with_fields(self, **kwargs) -> "ProjectInfo":
98        """
99        Create a copy of this project info with customized fields.
100        NamedTuple is immutable and this is a way to create modified copies.
101
102          info.enabled = True
103          info.mode = 1
104
105        can be done as follows:
106
107          modified = info.with_fields(enbled=True, mode=1)
108        """
109        return ProjectInfo(**{**self._asdict(), **kwargs})
110
111
112class ProjectMap:
113    """
114    Project map stores info about all the "registered" projects.
115    """
116
117    def __init__(self, path: Optional[str] = None, should_exist: bool = True):
118        """
119        :param path: optional path to a project JSON file, when None defaults
120                     to DEFAULT_MAP_FILE.
121        :param should_exist: flag to tell if it's an exceptional situation when
122                             the project file doesn't exist, creates an empty
123                             project list instead if we are not expecting it to
124                             exist.
125        """
126        if path is None:
127            path = os.path.join(os.path.abspath(os.curdir), DEFAULT_MAP_FILE)
128
129        if not os.path.exists(path):
130            if should_exist:
131                raise ValueError(
132                    f"Cannot find the project map file {path}"
133                    f"\nRunning script for the wrong directory?\n"
134                )
135            else:
136                self._create_empty(path)
137
138        self.path = path
139        self._load_projects()
140
141    def save(self):
142        """
143        Save project map back to its original file.
144        """
145        self._save(self.projects, self.path)
146
147    def _load_projects(self):
148        with open(self.path) as raw_data:
149            raw_projects = json.load(raw_data)
150
151            if not isinstance(raw_projects, list):
152                raise ValueError("Project map should be a list of JSON objects")
153
154            self.projects = self._parse(raw_projects)
155
156    @staticmethod
157    def _parse(raw_projects: List[JSON]) -> List[ProjectInfo]:
158        return [ProjectMap._parse_project(raw_project) for raw_project in raw_projects]
159
160    @staticmethod
161    def _parse_project(raw_project: JSON) -> ProjectInfo:
162        try:
163            name: str = raw_project["name"]
164            build_mode: int = raw_project["mode"]
165            enabled: bool = raw_project.get("enabled", True)
166            source: DownloadType = raw_project.get("source", "zip")
167            size = Size.from_str(raw_project.get("size", None))
168
169            if source == DownloadType.GIT:
170                origin, commit = ProjectMap._get_git_params(raw_project)
171            else:
172                origin, commit = "", ""
173
174            return ProjectInfo(name, build_mode, source, origin, commit, enabled, size)
175
176        except KeyError as e:
177            raise ValueError(f"Project info is required to have a '{e.args[0]}' field")
178
179    @staticmethod
180    def _get_git_params(raw_project: JSON) -> Tuple[str, str]:
181        try:
182            return raw_project["origin"], raw_project["commit"]
183        except KeyError as e:
184            raise ValueError(
185                f"Profect info is required to have a '{e.args[0]}' field "
186                f"if it has a 'git' source"
187            )
188
189    @staticmethod
190    def _create_empty(path: str):
191        ProjectMap._save([], path)
192
193    @staticmethod
194    def _save(projects: List[ProjectInfo], path: str):
195        with open(path, "w") as output:
196            json.dump(ProjectMap._convert_infos_to_dicts(projects), output, indent=2)
197
198    @staticmethod
199    def _convert_infos_to_dicts(projects: List[ProjectInfo]) -> List[JSON]:
200        return [ProjectMap._convert_info_to_dict(project) for project in projects]
201
202    @staticmethod
203    def _convert_info_to_dict(project: ProjectInfo) -> JSON:
204        whole_dict = project._asdict()
205        defaults = project._field_defaults
206
207        # there is no need in serializing fields with default values
208        for field, default_value in defaults.items():
209            if whole_dict[field] == default_value:
210                del whole_dict[field]
211
212        return whole_dict
213