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