A VCS repository archival tool
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

387 lines
14 KiB

  1. import abc
  2. #import codearchiver.modules # In get_module_class
  3. import codearchiver.storage
  4. import codearchiver.version
  5. import collections
  6. import contextlib
  7. import dataclasses
  8. import functools
  9. import logging
  10. import os
  11. import queue
  12. import requests
  13. import time
  14. import typing
  15. import weakref
  16. _logger = logging.getLogger(__name__)
  17. class InputURL:
  18. '''
  19. An input URL
  20. This primarily exists so multiple modules can access the content behind the URL for checks in `Module.matches` without fetching multiple times.
  21. It also handles the module name prefix in the scheme part of the URL. Note that `InputURL.url` is then the part without the module name.
  22. '''
  23. def __init__(self, url: str):
  24. if 0 < url.find('+') < url.find('://'):
  25. # '+' and '://' appear in the URL in this order and there is at least one character each before the + as well as between the two
  26. self._moduleScheme, self._url = url.split('+', 1)
  27. else:
  28. self._moduleScheme = None
  29. self._url = url
  30. self._response = None
  31. @property
  32. def url(self) -> str:
  33. '''URL without the module scheme prefix (if any)'''
  34. return self._url
  35. @property
  36. def moduleScheme(self) -> typing.Optional[str]:
  37. '''Module scheme prefix (if one is included, else `None`)'''
  38. return self._moduleScheme
  39. @property
  40. def content(self) -> str:
  41. '''HTTP response body upon fetching the URL with GET'''
  42. if self._response is None:
  43. self._response = HttpClient().get(self.url)
  44. return self._response.text
  45. def __repr__(self):
  46. return f'{type(self).__module__}.{type(self).__name__}({self._url!r})'
  47. @dataclasses.dataclass
  48. class Result:
  49. '''Container for the result of a module'''
  50. id: str
  51. '''A unique ID for this result'''
  52. files: list[tuple[str, typing.Optional['Index']]] = dataclasses.field(default_factory = list)
  53. '''List of filenames produced by the run, optionally with an index'''
  54. submoduleResults: list[tuple['Module', 'Result']] = dataclasses.field(default_factory = list)
  55. '''List of related submodules and their results'''
  56. class IndexValidationError(ValueError):
  57. pass
  58. @dataclasses.dataclass
  59. class IndexField:
  60. key: str
  61. required: bool
  62. repeatable: bool
  63. class Index(list[tuple[str, str]]):
  64. '''An index (key-value mapping, possibly with repeated keys) of a file produced by a module'''
  65. fields: list[IndexField] = []
  66. '''The fields for this index'''
  67. def append(self, *args):
  68. if len(args) == 1:
  69. args = args[0]
  70. return super().append(args)
  71. def validate(self):
  72. '''Check that all keys and values in the index conform to the specification'''
  73. keyCounts = collections.Counter(key for key, _ in self)
  74. keys = set(keyCounts)
  75. permittedKeys = set(field.key for field in type(self).fields)
  76. unrecognisedKeys = keys - permittedKeys
  77. if unrecognisedKeys:
  78. raise IndexValidationError(f'Unrecognised key(s): {", ".join(sorted(unrecognisedKeys))}')
  79. requiredKeys = set(field.key for field in type(self).fields if field.required)
  80. missingRequiredKeys = requiredKeys - keys
  81. if missingRequiredKeys:
  82. raise IndexValidationError(f'Missing required key(s): {", ".join(sorted(missingRequiredKeys))}')
  83. repeatableKeys = set(field.key for field in type(self).fields if field.repeatable)
  84. repeatedKeys = set(key for key, count in keyCounts.items() if count > 1)
  85. repeatedUnrepeatableKeys = repeatedKeys - repeatableKeys
  86. if repeatedUnrepeatableKeys:
  87. raise IndexValidationError(f'Repeated unrepeatable key(s): {", ".join(sorted(repeatedUnrepeatableKeys))}')
  88. def matches(self, criteria: list[tuple[str, typing.Union[str, tuple[str]]]]) -> bool:
  89. '''
  90. Check whether the criteria match this index
  91. Each criterion consists of a key and one or more possible values. A criterion matches if at least one of the specified values is present in the index.
  92. Multiple criteria may use the same key to perform an AND search.
  93. The index is a match if all criteria match.
  94. '''
  95. criteria = criteria.copy()
  96. _logger.debug(f'Searching index for {criteria!r}')
  97. keysOfInterest = set(key for key, _ in criteria)
  98. for key, value in self:
  99. if key not in keysOfInterest:
  100. continue
  101. _logger.debug(f'Potentially interesting entry: {key!r} = {value!r}')
  102. matched = [] # Indices to remove from remaining criteria
  103. for i, (keyCriterion, valueCriterion) in enumerate(criteria):
  104. if keyCriterion != key:
  105. continue
  106. if isinstance(valueCriterion, str) and valueCriterion == value:
  107. _logger.debug('Str match')
  108. matched.append(i)
  109. elif isinstance(valueCriterion, tuple) and value in valueCriterion:
  110. _logger.debug('Tuple match')
  111. matched.append(i)
  112. for i in reversed(matched):
  113. _logger.debug(f'Matched remaining criterion {i}: {criteria[i]}')
  114. del criteria[i]
  115. if not criteria:
  116. break
  117. _logger.debug(f'Remaining unmatched criteria: {criteria!r}')
  118. return not bool(criteria)
  119. def serialise(self) -> str:
  120. '''Convert the index to a string suitable for e.g. a simple text file storage'''
  121. self.validate()
  122. return ''.join(f'{key}: {value}\n' for key, value in self)
  123. @classmethod
  124. def deserialise(cls, f: typing.Union[str, bytes, os.PathLike, typing.TextIO], *, validate = True):
  125. '''Import a serialised index from a filename or file-like object'''
  126. if isinstance(f, (str, bytes, os.PathLike)):
  127. cm = open(f, 'r')
  128. else:
  129. cm = contextlib.nullcontext(f)
  130. with cm as fp:
  131. o = cls((key, value[:-1]) for key, value in map(functools.partial(str.split, sep = ': '), fp))
  132. if validate:
  133. o.validate()
  134. return o
  135. class HttpError(Exception):
  136. '''An HTTP request failed too many times.'''
  137. class HttpClient:
  138. '''A thin wrapper HTTP client around Requests with exponential backoff retries and a default user agent for all requests.'''
  139. defaultRetries: int = 3
  140. '''Default number of retries on errors unless overridden on creating the HttpClient object'''
  141. defaultUserAgent: str = f'codearchiver/{codearchiver.version.__version__}'
  142. '''Default user agent unless overridden on instantiation or by overriding via the headers kwarg'''
  143. def __init__(self, retries: typing.Optional[int] = None, userAgent: typing.Optional[str] = None):
  144. self._session = requests.Session()
  145. self._retries = retries if retries else self.defaultRetries
  146. self._userAgent = userAgent if userAgent else self.defaultUserAgent
  147. def request(self,
  148. method,
  149. url,
  150. params = None,
  151. data = None,
  152. headers: typing.Optional[dict[str, str]] = None,
  153. timeout: int = 10,
  154. responseOkCallback: typing.Optional[typing.Callable[[requests.Response], tuple[bool, typing.Optional[str]]]] = None,
  155. ) -> requests.Response:
  156. '''
  157. Make an HTTP request
  158. For the details on `method`, `url`, `params`, and `data`, refer to the Requests documentation on the constructor of `requests.Request`.
  159. For details on `timeout`, see `requests.adapters.HTTPAdapter.send`.
  160. `headers` can be used to specify any HTTP headers. Note that this is case-sensitive. To override the user agent, include a value for the `User-Agent` key here.
  161. `responseOkCallback` can be used to control whether a response is considered acceptable or not. By default, all HTTP responses are considered fine. If specified, this callable must produce a boolean marking whether the response is successful and an error message string. The string is used for logging purposes when the success flag is `False`; it should be `None` if the first return value is `True`.
  162. '''
  163. mergedHeaders = {'User-Agent': self._userAgent}
  164. if headers:
  165. mergedHeaders.update(headers)
  166. headers = mergedHeaders
  167. for attempt in range(self._retries + 1):
  168. # The request is newly prepared on each retry because of potential cookie updates.
  169. req = self._session.prepare_request(requests.Request(method, url, params = params, data = data, headers = headers))
  170. _logger.info(f'Retrieving {req.url}')
  171. _logger.debug(f'... with headers: {headers!r}')
  172. if data:
  173. _logger.debug(f'... with data: {data!r}')
  174. try:
  175. r = self._session.send(req, timeout = timeout)
  176. except requests.exceptions.RequestException as exc:
  177. if attempt < self._retries:
  178. retrying = ', retrying'
  179. level = logging.WARNING
  180. else:
  181. retrying = ''
  182. level = logging.ERROR
  183. _logger.log(level, f'Error retrieving {req.url}: {exc!r}{retrying}')
  184. else:
  185. if responseOkCallback is not None:
  186. success, msg = responseOkCallback(r)
  187. else:
  188. success, msg = (True, None)
  189. msg = f': {msg}' if msg else ''
  190. if success:
  191. _logger.debug(f'{req.url} retrieved successfully{msg}')
  192. return r
  193. else:
  194. if attempt < self._retries:
  195. retrying = ', retrying'
  196. level = logging.WARNING
  197. else:
  198. retrying = ''
  199. level = logging.ERROR
  200. _logger.log(level, f'Error retrieving {req.url}{msg}{retrying}')
  201. if attempt < self._retries:
  202. sleepTime = 1.0 * 2**attempt # exponential backoff: sleep 1 second after first attempt, 2 after second, 4 after third, etc.
  203. _logger.info(f'Waiting {sleepTime:.0f} seconds')
  204. time.sleep(sleepTime)
  205. else:
  206. msg = f'{self._retries + 1} requests to {req.url} failed, giving up.'
  207. _logger.fatal(msg)
  208. raise HttpError(msg)
  209. raise RuntimeError('Reached unreachable code')
  210. def get(self, *args, **kwargs):
  211. '''Make a GET request. This is equivalent to calling `.request('GET', ...)`.'''
  212. return self.request('GET', *args, **kwargs)
  213. def post(self, *args, **kwargs):
  214. '''Make a POST request. This is equivalent to calling `.request('POST', ...)`.'''
  215. return self.request('POST', *args, **kwargs)
  216. class ModuleMeta(type):
  217. '''Metaclass of modules. This is used to keep track of which modules exist and selecting them. It also enforces module name restrictions and prevents name collisions.'''
  218. __modulesByName: dict[str, typing.Type['Module']] = {}
  219. def __new__(cls, *args, **kwargs):
  220. class_ = super().__new__(cls, *args, **kwargs)
  221. if class_.name is not None:
  222. if class_.name.strip('abcdefghijklmnopqrstuvwxyz-') != '':
  223. raise RuntimeError(f'Invalid class name: {class_.name!r}')
  224. if class_.name in cls.__modulesByName:
  225. raise RuntimeError(f'Class name collision: {class_.name!r} is already known')
  226. cls.__modulesByName[class_.name] = weakref.ref(class_)
  227. _logger.info(f'Found {class_.name!r} module {class_.__module__}.{class_.__name__}')
  228. else:
  229. _logger.info(f'Found nameless module {class_.__module__}.{class_.__name__}')
  230. return class_
  231. @classmethod
  232. def get_module_by_name(cls, name: str) -> typing.Optional[typing.Type['Module']]:
  233. '''Get a module by name if one exists'''
  234. if classRef := cls.__modulesByName.get(name):
  235. class_ = classRef()
  236. if class_ is None:
  237. _logger.info(f'Module {name!r} is gone, dropping')
  238. del cls.__modulesByName[name]
  239. return class_
  240. @classmethod
  241. def iter_modules(cls) -> typing.Iterator[typing.Type['Module']]:
  242. '''Iterate over all known modules'''
  243. # Housekeeping first: remove dead modules
  244. for name in list(cls.__modulesByName): # create a copy of the names list so the dict can be modified in the loop
  245. if cls.__modulesByName[name]() is None:
  246. _logger.info(f'Module {name!r} is gone, dropping')
  247. del cls.__modulesByName[name]
  248. for name, classRef in cls.__modulesByName.items():
  249. class_ = classRef()
  250. if class_ is None:
  251. # Module class no longer exists, skip
  252. # Even though dead modules are removed above, it's possible that the code consuming this iterator drops/deletes modules.
  253. continue
  254. yield class_
  255. @classmethod
  256. def drop(cls, module: 'Module'):
  257. '''
  258. Remove a module from the list of known modules
  259. If a Module subclass is destroyed after `del MyModule`, it is also eventually removed from the list. However, as that relies on garbage collection, it should not be depended on and modules should be dropped with this method explicitly.
  260. '''
  261. if module.name is not None and module.name in cls.__modulesByName:
  262. del cls.__modulesByName[module.name]
  263. _logger.info(f'Module {module.name!r} dropped')
  264. def __del__(self, *args, **kwargs):
  265. if self.name is not None and self.name in type(self).__modulesByName:
  266. _logger.info(f'Module {self.name!r} is being destroyed, dropping')
  267. del type(self).__modulesByName[self.name]
  268. # type has no __del__ method, no need to call it.
  269. class Module(metaclass = ModuleMeta):
  270. '''An abstract base class for a module.'''
  271. name: typing.Optional[str] = None
  272. '''The name of the module. Modules without a name are ignored. Names must be unique and may only contain a-z and hyphens.'''
  273. @staticmethod
  274. def matches(inputUrl: InputURL) -> bool:
  275. '''Whether or not this module is for handling `inputUrl`.'''
  276. return False
  277. def __init__(self, inputUrl: InputURL, storage: typing.Optional[codearchiver.storage.Storage] = None, id_: typing.Optional[str] = None):
  278. self._inputUrl = inputUrl
  279. self._url = inputUrl.url
  280. self._storage = storage
  281. self._id = id_
  282. self._httpClient = HttpClient()
  283. @abc.abstractmethod
  284. def process(self) -> Result:
  285. '''Perform the relevant retrieval(s)'''
  286. def __repr__(self):
  287. return f'{type(self).__module__}.{type(self).__name__}({self._inputUrl!r})'
  288. def get_module_class(inputUrl: InputURL) -> typing.Type[Module]:
  289. '''Get the Module class most suitable for handling `inputUrl`.'''
  290. # Ensure that modules are imported
  291. # This can't be done at the top because the modules need to refer back to the Module class.
  292. import codearchiver.modules
  293. # Check if the URL references one of the modules directly
  294. if inputUrl.moduleScheme:
  295. if module := ModuleMeta.get_module_by_name(inputUrl.moduleScheme):
  296. _logger.info(f'Selecting module {module.__module__}.{module.__name__}')
  297. return module
  298. else:
  299. raise RuntimeError(f'No module with name {inputUrl.moduleScheme!r} exists')
  300. # Check if exactly one of the modules matches
  301. matches = [class_ for class_ in ModuleMeta.iter_modules() if class_.matches(inputUrl)]
  302. if len(matches) >= 2:
  303. _logger.error('Multiple matching modules for input URL')
  304. _logger.debug(f'Matching modules: {matches!r}')
  305. raise RuntimeError('Multiple matching modules for input URL')
  306. if matches:
  307. _logger.info(f'Selecting module {matches[0].__module__}.{matches[0].__name__}')
  308. return matches[0]
  309. raise RuntimeError('No matching modules for input URL')
  310. def get_module_instance(inputUrl: InputURL, **kwargs) -> Module:
  311. '''Get an instance of the Module class most suitable for handling `inputUrl`.'''
  312. return get_module_class(inputUrl)(inputUrl, **kwargs)