Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

config.py 23KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749
  1. import ast
  2. import io
  3. import os
  4. import sys
  5. import warnings
  6. import functools
  7. import importlib
  8. from collections import defaultdict
  9. from functools import partial
  10. from functools import wraps
  11. from glob import iglob
  12. import contextlib
  13. from distutils.errors import DistutilsOptionError, DistutilsFileError
  14. from setuptools.extern.packaging.version import LegacyVersion, parse
  15. from setuptools.extern.packaging.specifiers import SpecifierSet
  16. class StaticModule:
  17. """
  18. Attempt to load the module by the name
  19. """
  20. def __init__(self, name):
  21. spec = importlib.util.find_spec(name)
  22. with open(spec.origin) as strm:
  23. src = strm.read()
  24. module = ast.parse(src)
  25. vars(self).update(locals())
  26. del self.self
  27. def __getattr__(self, attr):
  28. try:
  29. return next(
  30. ast.literal_eval(statement.value)
  31. for statement in self.module.body
  32. if isinstance(statement, ast.Assign)
  33. for target in statement.targets
  34. if isinstance(target, ast.Name) and target.id == attr
  35. )
  36. except Exception as e:
  37. raise AttributeError(
  38. "{self.name} has no attribute {attr}".format(**locals())
  39. ) from e
  40. @contextlib.contextmanager
  41. def patch_path(path):
  42. """
  43. Add path to front of sys.path for the duration of the context.
  44. """
  45. try:
  46. sys.path.insert(0, path)
  47. yield
  48. finally:
  49. sys.path.remove(path)
  50. def read_configuration(filepath, find_others=False, ignore_option_errors=False):
  51. """Read given configuration file and returns options from it as a dict.
  52. :param str|unicode filepath: Path to configuration file
  53. to get options from.
  54. :param bool find_others: Whether to search for other configuration files
  55. which could be on in various places.
  56. :param bool ignore_option_errors: Whether to silently ignore
  57. options, values of which could not be resolved (e.g. due to exceptions
  58. in directives such as file:, attr:, etc.).
  59. If False exceptions are propagated as expected.
  60. :rtype: dict
  61. """
  62. from setuptools.dist import Distribution, _Distribution
  63. filepath = os.path.abspath(filepath)
  64. if not os.path.isfile(filepath):
  65. raise DistutilsFileError('Configuration file %s does not exist.' % filepath)
  66. current_directory = os.getcwd()
  67. os.chdir(os.path.dirname(filepath))
  68. try:
  69. dist = Distribution()
  70. filenames = dist.find_config_files() if find_others else []
  71. if filepath not in filenames:
  72. filenames.append(filepath)
  73. _Distribution.parse_config_files(dist, filenames=filenames)
  74. handlers = parse_configuration(
  75. dist, dist.command_options, ignore_option_errors=ignore_option_errors
  76. )
  77. finally:
  78. os.chdir(current_directory)
  79. return configuration_to_dict(handlers)
  80. def _get_option(target_obj, key):
  81. """
  82. Given a target object and option key, get that option from
  83. the target object, either through a get_{key} method or
  84. from an attribute directly.
  85. """
  86. getter_name = 'get_{key}'.format(**locals())
  87. by_attribute = functools.partial(getattr, target_obj, key)
  88. getter = getattr(target_obj, getter_name, by_attribute)
  89. return getter()
  90. def configuration_to_dict(handlers):
  91. """Returns configuration data gathered by given handlers as a dict.
  92. :param list[ConfigHandler] handlers: Handlers list,
  93. usually from parse_configuration()
  94. :rtype: dict
  95. """
  96. config_dict = defaultdict(dict)
  97. for handler in handlers:
  98. for option in handler.set_options:
  99. value = _get_option(handler.target_obj, option)
  100. config_dict[handler.section_prefix][option] = value
  101. return config_dict
  102. def parse_configuration(distribution, command_options, ignore_option_errors=False):
  103. """Performs additional parsing of configuration options
  104. for a distribution.
  105. Returns a list of used option handlers.
  106. :param Distribution distribution:
  107. :param dict command_options:
  108. :param bool ignore_option_errors: Whether to silently ignore
  109. options, values of which could not be resolved (e.g. due to exceptions
  110. in directives such as file:, attr:, etc.).
  111. If False exceptions are propagated as expected.
  112. :rtype: list
  113. """
  114. options = ConfigOptionsHandler(distribution, command_options, ignore_option_errors)
  115. options.parse()
  116. meta = ConfigMetadataHandler(
  117. distribution.metadata,
  118. command_options,
  119. ignore_option_errors,
  120. distribution.package_dir,
  121. )
  122. meta.parse()
  123. return meta, options
  124. class ConfigHandler:
  125. """Handles metadata supplied in configuration files."""
  126. section_prefix = None
  127. """Prefix for config sections handled by this handler.
  128. Must be provided by class heirs.
  129. """
  130. aliases = {}
  131. """Options aliases.
  132. For compatibility with various packages. E.g.: d2to1 and pbr.
  133. Note: `-` in keys is replaced with `_` by config parser.
  134. """
  135. def __init__(self, target_obj, options, ignore_option_errors=False):
  136. sections = {}
  137. section_prefix = self.section_prefix
  138. for section_name, section_options in options.items():
  139. if not section_name.startswith(section_prefix):
  140. continue
  141. section_name = section_name.replace(section_prefix, '').strip('.')
  142. sections[section_name] = section_options
  143. self.ignore_option_errors = ignore_option_errors
  144. self.target_obj = target_obj
  145. self.sections = sections
  146. self.set_options = []
  147. @property
  148. def parsers(self):
  149. """Metadata item name to parser function mapping."""
  150. raise NotImplementedError(
  151. '%s must provide .parsers property' % self.__class__.__name__
  152. )
  153. def __setitem__(self, option_name, value):
  154. unknown = tuple()
  155. target_obj = self.target_obj
  156. # Translate alias into real name.
  157. option_name = self.aliases.get(option_name, option_name)
  158. current_value = getattr(target_obj, option_name, unknown)
  159. if current_value is unknown:
  160. raise KeyError(option_name)
  161. if current_value:
  162. # Already inhabited. Skipping.
  163. return
  164. skip_option = False
  165. parser = self.parsers.get(option_name)
  166. if parser:
  167. try:
  168. value = parser(value)
  169. except Exception:
  170. skip_option = True
  171. if not self.ignore_option_errors:
  172. raise
  173. if skip_option:
  174. return
  175. setter = getattr(target_obj, 'set_%s' % option_name, None)
  176. if setter is None:
  177. setattr(target_obj, option_name, value)
  178. else:
  179. setter(value)
  180. self.set_options.append(option_name)
  181. @classmethod
  182. def _parse_list(cls, value, separator=','):
  183. """Represents value as a list.
  184. Value is split either by separator (defaults to comma) or by lines.
  185. :param value:
  186. :param separator: List items separator character.
  187. :rtype: list
  188. """
  189. if isinstance(value, list): # _get_parser_compound case
  190. return value
  191. if '\n' in value:
  192. value = value.splitlines()
  193. else:
  194. value = value.split(separator)
  195. return [chunk.strip() for chunk in value if chunk.strip()]
  196. @classmethod
  197. def _parse_list_glob(cls, value, separator=','):
  198. """Equivalent to _parse_list() but expands any glob patterns using glob().
  199. However, unlike with glob() calls, the results remain relative paths.
  200. :param value:
  201. :param separator: List items separator character.
  202. :rtype: list
  203. """
  204. glob_characters = ('*', '?', '[', ']', '{', '}')
  205. values = cls._parse_list(value, separator=separator)
  206. expanded_values = []
  207. for value in values:
  208. # Has globby characters?
  209. if any(char in value for char in glob_characters):
  210. # then expand the glob pattern while keeping paths *relative*:
  211. expanded_values.extend(sorted(
  212. os.path.relpath(path, os.getcwd())
  213. for path in iglob(os.path.abspath(value))))
  214. else:
  215. # take the value as-is:
  216. expanded_values.append(value)
  217. return expanded_values
  218. @classmethod
  219. def _parse_dict(cls, value):
  220. """Represents value as a dict.
  221. :param value:
  222. :rtype: dict
  223. """
  224. separator = '='
  225. result = {}
  226. for line in cls._parse_list(value):
  227. key, sep, val = line.partition(separator)
  228. if sep != separator:
  229. raise DistutilsOptionError(
  230. 'Unable to parse option value to dict: %s' % value
  231. )
  232. result[key.strip()] = val.strip()
  233. return result
  234. @classmethod
  235. def _parse_bool(cls, value):
  236. """Represents value as boolean.
  237. :param value:
  238. :rtype: bool
  239. """
  240. value = value.lower()
  241. return value in ('1', 'true', 'yes')
  242. @classmethod
  243. def _exclude_files_parser(cls, key):
  244. """Returns a parser function to make sure field inputs
  245. are not files.
  246. Parses a value after getting the key so error messages are
  247. more informative.
  248. :param key:
  249. :rtype: callable
  250. """
  251. def parser(value):
  252. exclude_directive = 'file:'
  253. if value.startswith(exclude_directive):
  254. raise ValueError(
  255. 'Only strings are accepted for the {0} field, '
  256. 'files are not accepted'.format(key)
  257. )
  258. return value
  259. return parser
  260. @classmethod
  261. def _parse_file(cls, value):
  262. """Represents value as a string, allowing including text
  263. from nearest files using `file:` directive.
  264. Directive is sandboxed and won't reach anything outside
  265. directory with setup.py.
  266. Examples:
  267. file: README.rst, CHANGELOG.md, src/file.txt
  268. :param str value:
  269. :rtype: str
  270. """
  271. include_directive = 'file:'
  272. if not isinstance(value, str):
  273. return value
  274. if not value.startswith(include_directive):
  275. return value
  276. spec = value[len(include_directive) :]
  277. filepaths = (os.path.abspath(path.strip()) for path in spec.split(','))
  278. return '\n'.join(
  279. cls._read_file(path)
  280. for path in filepaths
  281. if (cls._assert_local(path) or True) and os.path.isfile(path)
  282. )
  283. @staticmethod
  284. def _assert_local(filepath):
  285. if not filepath.startswith(os.getcwd()):
  286. raise DistutilsOptionError('`file:` directive can not access %s' % filepath)
  287. @staticmethod
  288. def _read_file(filepath):
  289. with io.open(filepath, encoding='utf-8') as f:
  290. return f.read()
  291. @classmethod
  292. def _parse_attr(cls, value, package_dir=None):
  293. """Represents value as a module attribute.
  294. Examples:
  295. attr: package.attr
  296. attr: package.module.attr
  297. :param str value:
  298. :rtype: str
  299. """
  300. attr_directive = 'attr:'
  301. if not value.startswith(attr_directive):
  302. return value
  303. attrs_path = value.replace(attr_directive, '').strip().split('.')
  304. attr_name = attrs_path.pop()
  305. module_name = '.'.join(attrs_path)
  306. module_name = module_name or '__init__'
  307. parent_path = os.getcwd()
  308. if package_dir:
  309. if attrs_path[0] in package_dir:
  310. # A custom path was specified for the module we want to import
  311. custom_path = package_dir[attrs_path[0]]
  312. parts = custom_path.rsplit('/', 1)
  313. if len(parts) > 1:
  314. parent_path = os.path.join(os.getcwd(), parts[0])
  315. module_name = parts[1]
  316. else:
  317. module_name = custom_path
  318. elif '' in package_dir:
  319. # A custom parent directory was specified for all root modules
  320. parent_path = os.path.join(os.getcwd(), package_dir[''])
  321. with patch_path(parent_path):
  322. try:
  323. # attempt to load value statically
  324. return getattr(StaticModule(module_name), attr_name)
  325. except Exception:
  326. # fallback to simple import
  327. module = importlib.import_module(module_name)
  328. return getattr(module, attr_name)
  329. @classmethod
  330. def _get_parser_compound(cls, *parse_methods):
  331. """Returns parser function to represents value as a list.
  332. Parses a value applying given methods one after another.
  333. :param parse_methods:
  334. :rtype: callable
  335. """
  336. def parse(value):
  337. parsed = value
  338. for method in parse_methods:
  339. parsed = method(parsed)
  340. return parsed
  341. return parse
  342. @classmethod
  343. def _parse_section_to_dict(cls, section_options, values_parser=None):
  344. """Parses section options into a dictionary.
  345. Optionally applies a given parser to values.
  346. :param dict section_options:
  347. :param callable values_parser:
  348. :rtype: dict
  349. """
  350. value = {}
  351. values_parser = values_parser or (lambda val: val)
  352. for key, (_, val) in section_options.items():
  353. value[key] = values_parser(val)
  354. return value
  355. def parse_section(self, section_options):
  356. """Parses configuration file section.
  357. :param dict section_options:
  358. """
  359. for (name, (_, value)) in section_options.items():
  360. try:
  361. self[name] = value
  362. except KeyError:
  363. pass # Keep silent for a new option may appear anytime.
  364. def parse(self):
  365. """Parses configuration file items from one
  366. or more related sections.
  367. """
  368. for section_name, section_options in self.sections.items():
  369. method_postfix = ''
  370. if section_name: # [section.option] variant
  371. method_postfix = '_%s' % section_name
  372. section_parser_method = getattr(
  373. self,
  374. # Dots in section names are translated into dunderscores.
  375. ('parse_section%s' % method_postfix).replace('.', '__'),
  376. None,
  377. )
  378. if section_parser_method is None:
  379. raise DistutilsOptionError(
  380. 'Unsupported distribution option section: [%s.%s]'
  381. % (self.section_prefix, section_name)
  382. )
  383. section_parser_method(section_options)
  384. def _deprecated_config_handler(self, func, msg, warning_class):
  385. """this function will wrap around parameters that are deprecated
  386. :param msg: deprecation message
  387. :param warning_class: class of warning exception to be raised
  388. :param func: function to be wrapped around
  389. """
  390. @wraps(func)
  391. def config_handler(*args, **kwargs):
  392. warnings.warn(msg, warning_class)
  393. return func(*args, **kwargs)
  394. return config_handler
  395. class ConfigMetadataHandler(ConfigHandler):
  396. section_prefix = 'metadata'
  397. aliases = {
  398. 'home_page': 'url',
  399. 'summary': 'description',
  400. 'classifier': 'classifiers',
  401. 'platform': 'platforms',
  402. }
  403. strict_mode = False
  404. """We need to keep it loose, to be partially compatible with
  405. `pbr` and `d2to1` packages which also uses `metadata` section.
  406. """
  407. def __init__(
  408. self, target_obj, options, ignore_option_errors=False, package_dir=None
  409. ):
  410. super(ConfigMetadataHandler, self).__init__(
  411. target_obj, options, ignore_option_errors
  412. )
  413. self.package_dir = package_dir
  414. @property
  415. def parsers(self):
  416. """Metadata item name to parser function mapping."""
  417. parse_list = self._parse_list
  418. parse_file = self._parse_file
  419. parse_dict = self._parse_dict
  420. exclude_files_parser = self._exclude_files_parser
  421. return {
  422. 'platforms': parse_list,
  423. 'keywords': parse_list,
  424. 'provides': parse_list,
  425. 'requires': self._deprecated_config_handler(
  426. parse_list,
  427. "The requires parameter is deprecated, please use "
  428. "install_requires for runtime dependencies.",
  429. DeprecationWarning,
  430. ),
  431. 'obsoletes': parse_list,
  432. 'classifiers': self._get_parser_compound(parse_file, parse_list),
  433. 'license': exclude_files_parser('license'),
  434. 'license_file': self._deprecated_config_handler(
  435. exclude_files_parser('license_file'),
  436. "The license_file parameter is deprecated, "
  437. "use license_files instead.",
  438. DeprecationWarning,
  439. ),
  440. 'license_files': parse_list,
  441. 'description': parse_file,
  442. 'long_description': parse_file,
  443. 'version': self._parse_version,
  444. 'project_urls': parse_dict,
  445. }
  446. def _parse_version(self, value):
  447. """Parses `version` option value.
  448. :param value:
  449. :rtype: str
  450. """
  451. version = self._parse_file(value)
  452. if version != value:
  453. version = version.strip()
  454. # Be strict about versions loaded from file because it's easy to
  455. # accidentally include newlines and other unintended content
  456. if isinstance(parse(version), LegacyVersion):
  457. tmpl = (
  458. 'Version loaded from {value} does not '
  459. 'comply with PEP 440: {version}'
  460. )
  461. raise DistutilsOptionError(tmpl.format(**locals()))
  462. return version
  463. version = self._parse_attr(value, self.package_dir)
  464. if callable(version):
  465. version = version()
  466. if not isinstance(version, str):
  467. if hasattr(version, '__iter__'):
  468. version = '.'.join(map(str, version))
  469. else:
  470. version = '%s' % version
  471. return version
  472. class ConfigOptionsHandler(ConfigHandler):
  473. section_prefix = 'options'
  474. @property
  475. def parsers(self):
  476. """Metadata item name to parser function mapping."""
  477. parse_list = self._parse_list
  478. parse_list_semicolon = partial(self._parse_list, separator=';')
  479. parse_bool = self._parse_bool
  480. parse_dict = self._parse_dict
  481. parse_cmdclass = self._parse_cmdclass
  482. return {
  483. 'zip_safe': parse_bool,
  484. 'include_package_data': parse_bool,
  485. 'package_dir': parse_dict,
  486. 'scripts': parse_list,
  487. 'eager_resources': parse_list,
  488. 'dependency_links': parse_list,
  489. 'namespace_packages': parse_list,
  490. 'install_requires': parse_list_semicolon,
  491. 'setup_requires': parse_list_semicolon,
  492. 'tests_require': parse_list_semicolon,
  493. 'packages': self._parse_packages,
  494. 'entry_points': self._parse_file,
  495. 'py_modules': parse_list,
  496. 'python_requires': SpecifierSet,
  497. 'cmdclass': parse_cmdclass,
  498. }
  499. def _parse_cmdclass(self, value):
  500. def resolve_class(qualified_class_name):
  501. idx = qualified_class_name.rfind('.')
  502. class_name = qualified_class_name[idx + 1 :]
  503. pkg_name = qualified_class_name[:idx]
  504. module = __import__(pkg_name)
  505. return getattr(module, class_name)
  506. return {k: resolve_class(v) for k, v in self._parse_dict(value).items()}
  507. def _parse_packages(self, value):
  508. """Parses `packages` option value.
  509. :param value:
  510. :rtype: list
  511. """
  512. find_directives = ['find:', 'find_namespace:']
  513. trimmed_value = value.strip()
  514. if trimmed_value not in find_directives:
  515. return self._parse_list(value)
  516. findns = trimmed_value == find_directives[1]
  517. # Read function arguments from a dedicated section.
  518. find_kwargs = self.parse_section_packages__find(
  519. self.sections.get('packages.find', {})
  520. )
  521. if findns:
  522. from setuptools import find_namespace_packages as find_packages
  523. else:
  524. from setuptools import find_packages
  525. return find_packages(**find_kwargs)
  526. def parse_section_packages__find(self, section_options):
  527. """Parses `packages.find` configuration file section.
  528. To be used in conjunction with _parse_packages().
  529. :param dict section_options:
  530. """
  531. section_data = self._parse_section_to_dict(section_options, self._parse_list)
  532. valid_keys = ['where', 'include', 'exclude']
  533. find_kwargs = dict(
  534. [(k, v) for k, v in section_data.items() if k in valid_keys and v]
  535. )
  536. where = find_kwargs.get('where')
  537. if where is not None:
  538. find_kwargs['where'] = where[0] # cast list to single val
  539. return find_kwargs
  540. def parse_section_entry_points(self, section_options):
  541. """Parses `entry_points` configuration file section.
  542. :param dict section_options:
  543. """
  544. parsed = self._parse_section_to_dict(section_options, self._parse_list)
  545. self['entry_points'] = parsed
  546. def _parse_package_data(self, section_options):
  547. parsed = self._parse_section_to_dict(section_options, self._parse_list)
  548. root = parsed.get('*')
  549. if root:
  550. parsed[''] = root
  551. del parsed['*']
  552. return parsed
  553. def parse_section_package_data(self, section_options):
  554. """Parses `package_data` configuration file section.
  555. :param dict section_options:
  556. """
  557. self['package_data'] = self._parse_package_data(section_options)
  558. def parse_section_exclude_package_data(self, section_options):
  559. """Parses `exclude_package_data` configuration file section.
  560. :param dict section_options:
  561. """
  562. self['exclude_package_data'] = self._parse_package_data(section_options)
  563. def parse_section_extras_require(self, section_options):
  564. """Parses `extras_require` configuration file section.
  565. :param dict section_options:
  566. """
  567. parse_list = partial(self._parse_list, separator=';')
  568. self['extras_require'] = self._parse_section_to_dict(
  569. section_options, parse_list
  570. )
  571. def parse_section_data_files(self, section_options):
  572. """Parses `data_files` configuration file section.
  573. :param dict section_options:
  574. """
  575. parsed = self._parse_section_to_dict(section_options, self._parse_list_glob)
  576. self['data_files'] = [(k, v) for k, v in parsed.items()]