Home | History | Annotate | Line # | Download | only in analyzer
      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
    209