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