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