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 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 78class 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 105class 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 209