DEPRECATED -- Rewritten and moved to https://github.com/NiklasRosenstein/shut/. 🌊 Shore is a distribution and release management tool for pure Python packages.
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.
 

827 lines
28 KiB

  1. # -*- coding: utf8 -*-
  2. # Copyright (c) 2019 Niklas Rosenstein
  3. #
  4. # Permission is hereby granted, free of charge, to any person obtaining a copy
  5. # of this software and associated documentation files (the "Software"), to
  6. # deal in the Software without restriction, including without limitation the
  7. # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
  8. # sell copies of the Software, and to permit persons to whom the Software is
  9. # furnished to do so, subject to the following conditions:
  10. #
  11. # The above copyright notice and this permission notice shall be included in
  12. # all copies or substantial portions of the Software.
  13. #
  14. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
  19. # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
  20. # IN THE SOFTWARE.
  21. from fnmatch import fnmatch
  22. from nr.proxy import proxy_decorator
  23. from nr.stream import Stream
  24. from shore import __version__
  25. from shore.core.plugins import (
  26. CheckResult,
  27. FileToRender,
  28. IMonorepoPlugin,
  29. IPackagePlugin,
  30. VersionRef,
  31. write_to_disk)
  32. from shore.mapper import mapper
  33. from shore.model import Monorepo, ObjectCache, Package, VersionSelector
  34. from shore.plugins.core import get_monorepo_interdependency_version_refs
  35. from shore.util import git as _git
  36. from shore.util.changelog import ChangelogEntry, ChangelogManager
  37. from shore.util.classifiers import get_classifiers
  38. from shore.util.license import get_license_metadata, wrap_license_text
  39. from shore.util.resources import walk_package_resources
  40. from shore.util.version import get_commit_distance_version, parse_version, bump_version, Version
  41. from termcolor import colored
  42. from typing import Any, Dict, Iterable, List, Optional, Union
  43. import click
  44. import io
  45. import jinja2
  46. import json
  47. import logging
  48. import nr.fs
  49. import os
  50. import pkg_resources
  51. import shlex
  52. import subprocess
  53. import sys
  54. import yaml
  55. _cache = ObjectCache()
  56. logger = logging.getLogger(__name__)
  57. def _get_author_info_from_git():
  58. try:
  59. name = subprocess.getoutput('git config user.name')
  60. email = subprocess.getoutput('git config user.email')
  61. except FileNotFoundError:
  62. return None
  63. if not name and not email:
  64. return None
  65. return '{} <{}>'.format(name, email)
  66. def _commit_distance_version(subject: [Monorepo, Package]) -> Version:
  67. if isinstance(subject, Package) and subject.monorepo \
  68. and subject.monorepo.mono_versioning:
  69. subject = subject.monorepo
  70. return get_commit_distance_version(
  71. subject.directory,
  72. subject.version,
  73. subject.get_tag(subject.version)) or subject.version
  74. def _edit_text(text: str) -> str:
  75. """
  76. Opens an editor for the user to modify *text*.
  77. """
  78. editor = shlex.split(os.getenv('EDITOR', 'vim'))
  79. with nr.fs.tempfile('.yml', dir=os.getcwd(), text=True) as fp:
  80. fp.write(text)
  81. fp.close()
  82. res = subprocess.call(editor + [fp.name])
  83. if res != 0:
  84. sys.exit(res)
  85. with open(fp.name) as src:
  86. return src.read()
  87. def _load_subject(allow_none: bool = False) -> Union[Monorepo, Package, None]:
  88. package, monorepo = None, None
  89. if os.path.isfile('package.yaml'):
  90. package = Package.load('package.yaml', _cache)
  91. if os.path.isfile('monorepo.yaml'):
  92. monorepo = Monorepo.load('monorepo.yaml', _cache)
  93. if package and monorepo:
  94. raise RuntimeError('found package.yaml and monorepo.yaml in the same '
  95. 'directory')
  96. if not allow_none and not package and not monorepo:
  97. logger.error('no package.yaml or monorepo.yaml in current directory')
  98. exit(1)
  99. return package or monorepo
  100. @click.group()
  101. @click.option('-C', '--change-directory')
  102. @click.option('-v', '--verbose', is_flag=True)
  103. @click.version_option(version=__version__)
  104. def cli(change_directory, verbose):
  105. logging.basicConfig(
  106. format='[%(levelname)s:%(name)s]: %(message)s' if verbose else '%(message)s',
  107. level=logging.DEBUG if verbose else logging.INFO)
  108. if change_directory:
  109. os.chdir(change_directory)
  110. @cli.command()
  111. @click.argument('output_type', type=click.Choice(['json', 'text', 'notice']))
  112. @click.argument('license_name')
  113. def license(output_type, license_name):
  114. """ Print license information, full text or short notice. """
  115. data = get_license_metadata(license_name)
  116. if output_type == 'json':
  117. print(json.dumps(data(), sort_keys=True))
  118. elif output_type == 'text':
  119. print(wrap_license_text(data['license_text']))
  120. elif ouutput_type == 'notice':
  121. print(wrap_license_text(data['standard_notice'] or data['license_text']))
  122. else:
  123. raise RuntimeError(output_type)
  124. @cli.command('classifiers')
  125. @click.argument('q', required=False)
  126. def classifiers(q):
  127. """ Search for package classifiers on PyPI. """
  128. for classifier in get_classifiers():
  129. if not q or q.strip().lower() in classifier.lower():
  130. print(classifier)
  131. @cli.command()
  132. @click.argument('name')
  133. @click.argument('directory', required=False)
  134. @click.option('--author')
  135. @click.option('--version')
  136. @click.option('--license')
  137. @click.option('--modulename')
  138. @click.option('--monorepo', is_flag=True)
  139. @click.option('--dry', is_flag=True)
  140. @click.option('--force', '-f', is_flag=True)
  141. def new(**args):
  142. """ Initialize a new project or repository. """
  143. if not args['directory']:
  144. args['directory'] = args['name']
  145. if not args['author']:
  146. args['author'] = _get_author_info_from_git()
  147. env_vars = {
  148. 'name': args['name'],
  149. 'version': args['version'],
  150. 'author': args['author'],
  151. 'license': args['license'],
  152. 'modulename': args['modulename'],
  153. 'name_on_disk': args['modulename'] or args['name'],
  154. }
  155. name_on_disk = args['modulename'] or args['name']
  156. def _render_template(template_string, **kwargs):
  157. assert isinstance(template_string, str), type(template_string)
  158. return jinja2.Template(template_string).render(**(kwargs or env_vars))
  159. def _render_file(fp, filename):
  160. content = pkg_resources.resource_string('shore', filename).decode()
  161. fp.write(_render_template(content))
  162. def _render_namespace_file(fp):
  163. fp.write("__path__ = __import__('pkgutil').extend_path(__path__, __name__)\n")
  164. def _get_template_files(template_path) -> Iterable[FileToRender]:
  165. # Render the template files to the target directory.
  166. for source_filename in walk_package_resources('shore', template_path):
  167. # Expand variables in the filename.
  168. name = name_on_disk.replace('-', '_').replace('.', '/')
  169. filename = _render_template(source_filename, name=name)
  170. dest = os.path.join(args['directory'], filename)
  171. yield FileToRender(
  172. None,
  173. os.path.normpath(dest),
  174. lambda _, fp: _render_file(fp, template_path + '/' + source_filename))
  175. def _get_package_files() -> Iterable[FileToRender]:
  176. yield from _get_template_files('templates/package')
  177. # Render namespace supporting files.
  178. parts = []
  179. for item in name_on_disk.replace('-', '_').split('.')[:-1]:
  180. parts.append(item)
  181. dest = os.path.join(args['directory'], 'src', *parts, '__init__.py')
  182. yield FileToRender(
  183. None,
  184. os.path.normpath(dest),
  185. lambda _, fp: _render_namespace_file(fp))
  186. dest = os.path.join(args['directory'], 'src', 'test', *parts, '__init__.py')
  187. yield FileToRender(
  188. None,
  189. os.path.normpath(dest),
  190. lambda _, fp: fp.write('pass\n'))
  191. # TODO (@NiklasRosenstein): Render the license file if it does not exist.
  192. def _get_monorepo_files() -> Iterable[FileToRender]:
  193. yield from _get_template_files('templates/monorepo')
  194. if args['monorepo']:
  195. files = _get_monorepo_files()
  196. else:
  197. files = _get_package_files()
  198. for file in files:
  199. if os.path.isfile(file.name) and not args['force']:
  200. print(colored('Skip ' + file.name, 'yellow'))
  201. continue
  202. print(colored('Write ' + file.name, 'cyan'))
  203. if not args['dry']:
  204. write_to_disk(file)
  205. def _run_for_subject(subject: Union[Package, Monorepo], func) -> List[Any]:
  206. if isinstance(subject, Monorepo):
  207. subjects = [subject] + sorted(subject.get_packages(), key=lambda x: x.name)
  208. return [func(x) for x in subjects]
  209. else:
  210. return [func(subject)]
  211. def _color_subject_name(subject: Union[Package, Monorepo]) -> str:
  212. color = 'blue' if isinstance(subject, Monorepo) else 'cyan'
  213. return colored(subject.name, color)
  214. def _run_checks(subject, treat_warnings_as_errors: bool=False):
  215. def _collect_checks(subject):
  216. return Stream.concat(x.get_checks(subject) for x in subject.get_plugins())
  217. checks = Stream.concat(_run_for_subject(subject, _collect_checks)).collect()
  218. if not checks:
  219. logger.info('✔ no checks triggered')
  220. return True
  221. max_level = max(x.level for x in checks)
  222. if max_level == CheckResult.Level.INFO:
  223. status = 0
  224. elif max_level == CheckResult.Level.WARNING:
  225. status = 1 if treat_warnings_as_errors else 0
  226. elif max_level == CheckResult.Level.ERROR:
  227. status = 1
  228. else:
  229. assert False, max_level
  230. logger.info('%s %s check(s) triggered', '❌' if status != 0 else '✔',
  231. len(checks))
  232. colors = {'ERROR': 'red', 'WARNING': 'magenta', 'INFO': None}
  233. for check in checks:
  234. level = colored(check.level.name, colors[check.level.name])
  235. print(' {} ({}): {}'.format(level, _color_subject_name(check.on), check.message))
  236. logger.debug('exiting with status %s', status)
  237. return False
  238. @cli.command('check')
  239. @click.option('--treat-warnings-as-errors', is_flag=True)
  240. def checks(treat_warnings_as_errors):
  241. """ Run checks. """
  242. subject = _load_subject()
  243. if not _run_checks(subject, treat_warnings_as_errors):
  244. exit(1)
  245. exit(0)
  246. @cli.command('update')
  247. @click.option('--skip-checks', is_flag=True)
  248. @click.option('--dry', is_flag=True)
  249. @click.option('--stage', is_flag=True, help='Stage changed files in Git.')
  250. def update(skip_checks, dry, stage):
  251. """ (Re-)render files managed shore. """
  252. def _collect_files(subject):
  253. return Stream.concat(x.get_files(subject) for x in subject.get_plugins())
  254. subject = _load_subject()
  255. if not skip_checks:
  256. _run_checks(subject, True)
  257. files = _run_for_subject(subject, _collect_files)
  258. files = Stream.concat(files).collect()
  259. logger.info('⚪ rendering %s file(s)', len(files))
  260. for file in files:
  261. logger.info(' %s', os.path.relpath(file.name))
  262. if not dry:
  263. write_to_disk(file)
  264. if stage:
  265. _git.add([f.name for f in files])
  266. @cli.command('verify')
  267. @click.option('--tag', '-t', help='Specify the tag from CI checks to match with the tag produced by shore.')
  268. @click.option('--tag-check', type=click.Choice(['require', 'if-present', 'skip', 'ignore']), default='if-present')
  269. @click.option('--update-check', type=click.Choice(['require', 'skip', 'ignore']), default='require')
  270. def verify(tag, tag_check, update_check):
  271. """ Check whether "update" would change any files. """
  272. def _virtual_update(subject) -> Iterable[str]:
  273. files = Stream.concat(x.get_files(subject) for x in subject.get_plugins())
  274. for file in files:
  275. if not os.path.isfile(file.name):
  276. yield file.name
  277. continue
  278. fp = io.StringIO()
  279. write_to_disk(file, fp=fp)
  280. with io.open(file.name, newline='') as on_disk:
  281. if fp.getvalue() != on_disk.read():
  282. yield file.name
  283. def _tag_matcher(subject) -> Iterable[Union[Monorepo, Package]]:
  284. if isinstance(subject, Monorepo) and not subject.mono_versioning:
  285. # Tagging workflows on mono-repos without mono-versioning are not supported.
  286. return; yield
  287. if subject.get_tag(subject.version) == tag:
  288. yield subject
  289. status = 0
  290. subject = _load_subject()
  291. if update_check != 'skip':
  292. files = _run_for_subject(subject, _virtual_update)
  293. files = Stream.concat(files).collect()
  294. if files:
  295. logger.warning('❌ %s file(s) would be changed by an update.', len(files))
  296. if update_check != 'ignore':
  297. status = 1
  298. else:
  299. logger.info('✔ no files would be changed by an update.')
  300. for file in files:
  301. logger.warning(' %s', os.path.relpath(file))
  302. if tag_check != 'skip':
  303. if tag_check == 'require' and not tag:
  304. logger.error('❌ the specified tag is an empty string')
  305. status = 1
  306. elif tag:
  307. matches = _run_for_subject(subject, _tag_matcher)
  308. matches = Stream.concat(matches).collect()
  309. if len(matches) == 0:
  310. # TODO (@NiklasRosenstein): If we matched the {name} portion of the
  311. # tag_format (if present) we could find which package (or monorepo)
  312. # the tag was intended for.
  313. logger.error('❌ tag %s did not match any of the available subjects', tag)
  314. if tag_check != 'ignore':
  315. status = 1
  316. elif len(matches) > 1:
  317. logger.error('❌ tag matches multiple subjects: %s', tag)
  318. for match in matches:
  319. logger.error(' %s', match.name)
  320. if tag_check != 'ignore':
  321. status = 1
  322. else:
  323. logger.info('✔ tag %s matches %s', tag, matches[0].name)
  324. exit(status)
  325. def _get_version_refs(subject) -> List[VersionRef]:
  326. def _get(subject):
  327. for plugin in subject.get_plugins():
  328. yield plugin.get_version_refs(subject)
  329. if isinstance(subject, Monorepo) and subject.mono_versioning:
  330. version_refs = Stream.concat(_run_for_subject(subject, _get))
  331. else:
  332. version_refs = _get(subject)
  333. return Stream.concat(version_refs).collect()
  334. @cli.command('bump')
  335. @click.argument('version', required=False)
  336. @click.option('--major', is_flag=True)
  337. @click.option('--minor', is_flag=True)
  338. @click.option('--patch', is_flag=True)
  339. @click.option('--post', is_flag=True)
  340. @click.option('--snapshot', is_flag=True)
  341. @click.option('--tag', is_flag=True)
  342. @click.option('--dry', is_flag=True)
  343. @click.option('--skip-checks', is_flag=True)
  344. @click.option('--force', '-f', is_flag=True)
  345. @click.option('--allow-lower', is_flag=True)
  346. @click.option('--push', is_flag=True)
  347. @click.option('--update', is_flag=True)
  348. @click.option('--publish')
  349. def bump(**args):
  350. """ Bump version numbers. Either supply a target "version" (may require --force
  351. if the specified version is lower than the current) or specify one of the --major,
  352. --minor, --patch, --post or --snapshot flags.
  353. The "version" argument can also be one of the strings "major", "minor", "patch",
  354. "post" or "git" which is only for backwards compatibility and will be removed in a
  355. future version of shore.
  356. """
  357. subject = _load_subject()
  358. changelog_manager = ChangelogManager(subject.changelog_directory, mapper)
  359. bump_flags = ('major', 'minor', 'patch', 'post', 'snapshot')
  360. bump_args = ['--' + k for k in bump_flags if args[k]]
  361. if args['version']:
  362. bump_args.insert(0, '<version>')
  363. if len(bump_args) > 1:
  364. logger.error('incompatible arguments: ' + ', '.join(bump_args))
  365. exit(1)
  366. elif not bump_args:
  367. flags = ', '.join('--' + k for k in bump_flags)
  368. logger.error('missing arguments: specify a <version> or one of ' + flags)
  369. exit(1)
  370. # Warn for deprecated behavior.
  371. if args['version'] in ('post', 'patch', 'minor', 'major', 'git'):
  372. use_flag = '--' + args['version']
  373. if use_flag == '--git':
  374. use_flag = '--snapshot'
  375. logger.warning('Support for the %r argument is deprecated and will be removed in a '
  376. 'future version of Shore. Please use the %s flag instead.', args['version'], use_flag)
  377. if not args['skip_checks']:
  378. _run_checks(subject, True)
  379. if args['push'] and not args['tag']:
  380. logger.error('--push needs --tag')
  381. exit(1)
  382. if isinstance(subject, Package) and subject.monorepo \
  383. and subject.monorepo.mono_versioning:
  384. if args['force']:
  385. logger.warning('forcing version bump on individual package version '
  386. 'that is usually managed by the monorepo.')
  387. else:
  388. logger.error('cannot bump individual package version if managed by monorepo.')
  389. exit(1)
  390. version_refs = _get_version_refs(subject)
  391. if not version_refs:
  392. logger.error('no version refs found')
  393. exit(1)
  394. # Ensure the version is the same accross all refs.
  395. is_inconsistent = any(parse_version(x.value) != subject.version for x in version_refs)
  396. if is_inconsistent and not args['force']:
  397. logger.error('inconsistent versions across files need to be fixed first.')
  398. exit(1)
  399. elif is_inconsistent:
  400. logger.warning('inconsistent versions across files were found.')
  401. current_version = subject.version
  402. pep440_version = True
  403. if args['version'] == 'post' or args['post']:
  404. new_version = bump_version(current_version, 'post')
  405. elif args['version'] == 'patch' or args['patch']:
  406. new_version = bump_version(current_version, 'patch')
  407. elif args['version'] == 'minor' or args['minor']:
  408. new_version = bump_version(current_version, 'minor')
  409. elif args['version'] == 'major' or args['major']:
  410. new_version = bump_version(current_version, 'major')
  411. elif args['version'] == 'git' or args['snapshot']:
  412. new_version = _commit_distance_version(subject)
  413. args['allow_lower'] = True
  414. else:
  415. new_version = parse_version(args['version'])
  416. if not new_version.pep440_compliant:
  417. logger.warning('version "{}" is not PEP440 compliant.'.format(new_version))
  418. if new_version < current_version and not (args['force'] or args['allow_lower']):
  419. logger.error('version {} is lower than current version {}'.format(
  420. new_version, current_version))
  421. exit(1)
  422. # Comparing as strings to include the prerelease/build number in the
  423. # comparison.
  424. if str(new_version) == str(current_version) and not args['force']:
  425. logger.warning('new version {} is equal to current version {}'.format(
  426. new_version, current_version))
  427. exit(0)
  428. # The replacement below does not work if the same file is listed multiple
  429. # times so let's check for now that every file is listed only once.
  430. n_files = set(os.path.normpath(os.path.abspath(ref.filename))
  431. for ref in version_refs)
  432. assert len(n_files) == len(version_refs), "multiple version refs in one "\
  433. "file is not currently supported."
  434. logger.info('bumping %d version reference(s)', len(version_refs))
  435. for ref in version_refs:
  436. logger.info(' %s: %s → %s', os.path.relpath(ref.filename), ref.value, new_version)
  437. if not args['dry']:
  438. with open(ref.filename) as fp:
  439. contents = fp.read()
  440. contents = contents[:ref.start] + str(new_version) + contents[ref.end:]
  441. with open(ref.filename, 'w') as fp:
  442. fp.write(contents)
  443. # For monorepos using mono-versioning, we may need to bump cross-package references.
  444. if isinstance(subject, Monorepo) and subject.mono_versioning:
  445. version_sel_refs = list(get_monorepo_interdependency_version_refs(subject, new_version))
  446. logger.info('bumping %d monorepo inter-dependency requirement(s)', len(version_sel_refs))
  447. for group_key, refs in Stream.groupby(version_sel_refs, lambda r: r.filename, collect=list):
  448. logger.info(' %s:', os.path.relpath(group_key))
  449. with open(group_key) as fp:
  450. content = fp.read()
  451. offset = 0
  452. for ref in refs:
  453. logger.info(' %s %s → %s', ref.package, ref.sel, ref.new_sel)
  454. content = content[:ref.start - offset] + ref.new_sel + content[ref.end - offset:]
  455. offset += len(ref.sel) - len(ref.new_sel)
  456. if not args['dry']:
  457. with open(group_key, 'w') as fp:
  458. fp.write(content)
  459. if args['tag'] and version_sel_refs:
  460. logger.warning('bump requires an update in order to automatically tag')
  461. args['update'] = True
  462. # Rename the unreleased changelog if it exists.
  463. if changelog_manager.unreleased.exists():
  464. if args['dry']:
  465. changelog = changelog_manager.version(new_version)
  466. else:
  467. changelog = changelog_manager.release(new_version)
  468. logger.info('release staged changelog (%s → %s)', changelog_manager.unreleased.filename,
  469. changelog.filename)
  470. if args['update']:
  471. _cache.clear()
  472. try:
  473. update(['--stage'])
  474. except SystemExit as exc:
  475. if exc.code != 0:
  476. raise
  477. if args['tag']:
  478. if any(f.mode == 'A' for f in _git.porcelain()):
  479. logger.error('cannot tag with non-empty staging area')
  480. exit(1)
  481. tag_name = subject.get_tag(new_version)
  482. logger.info('tagging %s', tag_name)
  483. if not args['dry']:
  484. changed_files = [x.filename for x in version_refs]
  485. _git.add(changed_files)
  486. _git.commit('({}) bump version to {}'.format(subject.name, new_version), allow_empty=True)
  487. _git.tag(tag_name, force=args['force'])
  488. if not args['dry'] and args['push']:
  489. _git.push(_git.current_branch(), tag_name)
  490. if args['publish']:
  491. _cache.clear()
  492. publish([args['publish']])
  493. @cli.command('status')
  494. def status():
  495. """ Print the release status. """
  496. subject = _load_subject()
  497. def _get_commits_since_last_tag(subject):
  498. tag = subject.get_tag(subject.version)
  499. ref = _git.rev_parse(tag)
  500. if not ref:
  501. return tag, None
  502. else:
  503. return tag, len(_git.rev_list(tag + '..HEAD', subject.directory))
  504. items = [subject]
  505. if isinstance(subject, Monorepo):
  506. items.extend(sorted(subject.get_packages(), key=lambda x: x.name))
  507. if not subject.version:
  508. items.remove(subject)
  509. width = max(len(x.local_name) for x in items)
  510. for item in items:
  511. tag, num_commits = _get_commits_since_last_tag(item)
  512. if num_commits is None:
  513. item_info = colored('tag "{}" not found'.format(tag), 'red')
  514. elif num_commits == 0:
  515. item_info = colored('no commits', 'green') + ' since "{}"'.format(tag)
  516. else:
  517. item_info = colored('{} commit(s)'.format(num_commits), 'yellow') + ' since "{}"'.format(tag)
  518. print('{}: {}'.format(item.local_name.rjust(width), item_info))
  519. @cli.command()
  520. @click.option('--tag', '-t', is_flag=True)
  521. @click.option('--snapshot', '-s', is_flag=True)
  522. def version(tag, snapshot):
  523. """ Print the current package or repository version. """
  524. subject = _load_subject()
  525. version = _commit_distance_version(subject) if snapshot else subject.version
  526. if tag:
  527. print(subject.get_tag(version))
  528. else:
  529. print(version)
  530. @cli.command()
  531. @click.argument('args', nargs=-1)
  532. def git(args):
  533. """ Shortcut for running git commands with a version range since the last
  534. tag of the current package or repo.
  535. This is effectively a shortcut for
  536. \b
  537. git $1 `shore versions -ct`..HEAD $@ -- .
  538. """
  539. subject = _load_subject()
  540. tag = subject.get_tag(subject.version)
  541. command = ['git', args[0]] + [tag + '..HEAD'] + list(args[1:]) + ['--', '.']
  542. exit(subprocess.call(command))
  543. def _filter_targets(targets: Dict[str, Any], target: str) -> Dict[str, Any]:
  544. result = {}
  545. for key, value in targets.items():
  546. if fnmatch(key, target) or fnmatch(key, target + ':*'):
  547. result[key] = value
  548. return result
  549. @cli.command()
  550. @click.argument('target')
  551. @click.option('--build-dir', default='build',
  552. help='Override the build directory. Defaults to ./build')
  553. def build(**args):
  554. """ Build distributions. """
  555. subject = _load_subject()
  556. targets = subject.get_build_targets()
  557. if args['target']:
  558. targets = _filter_targets(targets, args['target'])
  559. if not targets:
  560. logging.error('no build targets matched "%s"', args['target'])
  561. exit(1)
  562. if not targets:
  563. logging.info('no build targets')
  564. exit(0)
  565. os.makedirs(args['build_dir'], exist_ok=True)
  566. for target_id, target in targets.items():
  567. logger.info('building target %s', colored(target_id, 'cyan'))
  568. target.build(args['build_dir'])
  569. @cli.command()
  570. @click.argument('target', required=False)
  571. @click.option('-l', '--list', is_flag=True)
  572. @click.option('-a', '--all', is_flag=True)
  573. @click.option('--build-dir', default='build',
  574. help='Override the build directory. Defaults to ./build')
  575. @click.option('--test', is_flag=True,
  576. help='Publish to a test repository instead.')
  577. @click.option('--build/--no-build', default=True,
  578. help='Always build artifacts before publishing. Enabled by default.')
  579. @click.option('--skip-existing', is_flag=True)
  580. def publish(**args):
  581. """ Publish a source distribution to PyPI. """
  582. subject = _load_subject()
  583. builds = subject.get_build_targets()
  584. publishers = subject.get_publish_targets()
  585. if args['all'] and isinstance(subject, Monorepo) and not subject.mono_versioning:
  586. logger.error('publish -a,--all not allowed for Monorepo without mono-versioning')
  587. exit(1)
  588. if args['target']:
  589. publishers = _filter_targets(publishers, args['target'])
  590. if not publishers:
  591. logger.error('no publish targets matched "%s"', args['target'])
  592. exit(1)
  593. if args['list']:
  594. if publishers:
  595. print('Publish targets for', colored(subject.name, 'cyan') + ':')
  596. for target in publishers:
  597. print(' ' + colored(target, 'yellow'))
  598. else:
  599. print('No publish targets for', colored(subject.name, 'cyan') + '.')
  600. exit(0)
  601. if not publishers or (not args['target'] and not args['all']):
  602. logging.info('no publish targets')
  603. exit(1)
  604. def _needs_build(build):
  605. for filename in build.get_build_artifacts():
  606. if not os.path.isfile(os.path.join(args['build_dir'], filename)):
  607. return True
  608. return False
  609. def _run_publisher(name, publisher):
  610. try:
  611. logging.info('collecting builds for "%s" ...', name)
  612. required_builds = {}
  613. for selector in publisher.get_build_selectors():
  614. selector_builds = _filter_targets(builds, selector)
  615. if not selector_builds:
  616. logger.error('selector "%s" could not be satisfied', selector)
  617. return False
  618. required_builds.update(selector_builds)
  619. for target_id, build in required_builds.items():
  620. if not args['build'] and not _needs_build(build):
  621. logger.info('skipping target %s', colored(target_id, 'cyan'))
  622. else:
  623. logger.info('building target %s', colored(target_id, 'cyan'))
  624. os.makedirs(args['build_dir'], exist_ok=True)
  625. build.build(args['build_dir'])
  626. publisher.publish(
  627. required_builds.values(),
  628. args['test'],
  629. args['build_dir'],
  630. args['skip_existing'])
  631. return True
  632. except:
  633. logger.exception('error while running publisher "%s"', name)
  634. return False
  635. status = 0
  636. for key, publisher in publishers.items():
  637. if not _run_publisher(key, publisher):
  638. status = 1
  639. logger.debug('exit with status code %s', status)
  640. exit(status)
  641. @cli.command()
  642. @click.option('-n', '--new', metavar='type',
  643. help='Create a new entry. The argument for this option is the changelog type. '
  644. '(usually one of {}).'.format(', '.join(ChangelogManager.TYPES)))
  645. @click.option('-m', '--message', metavar='text',
  646. help='The changelog entry description. Only with --new. If this is not provided, the EDITOR '
  647. 'will be opened to allow editing the changelog entry.')
  648. @click.option('-c', '--component', metavar='name', help='The component for the changelog entry.')
  649. @click.option('-F', '--flags', metavar='flag,…',
  650. help='Comma separated list of flags for the changelog entry.')
  651. @click.option('--commit', is_flag=True, help='Commit the changelog entry after creation.')
  652. def changelog(**args):
  653. """
  654. Show or create changelog entries.
  655. """
  656. subject = _load_subject(allow_none=True)
  657. if subject:
  658. manager = ChangelogManager(subject.changelog_directory, mapper)
  659. else:
  660. manager = ChangelogManager(Package.changelog_directory.default, mapper)
  661. if args['new']:
  662. if args['new'] not in manager.TYPES:
  663. logger.warning('"%s" is not a well-known changelog entry type.', args['new'])
  664. flags = list(filter(bool, map(str.strip, (args['flags'] or '').split(','))))
  665. entry = ChangelogEntry(args['new'], args['component'] or '', flags, args['message'] or '')
  666. if not entry.description:
  667. serialized = yaml.safe_dump(mapper.serialize(entry, ChangelogEntry), sort_keys=False)
  668. entry = mapper.deserialize(yaml.safe_load(_edit_text(serialized)), ChangelogEntry)
  669. if not entry.description:
  670. logger.error('no entry description provided.')
  671. sys.exit(1)
  672. if not entry.component:
  673. logger.error('no component provided.')
  674. created = not manager.unreleased.exists()
  675. manager.unreleased.add_entry(entry)
  676. manager.unreleased.save(create_directory=True)
  677. message = ('Created' if created else 'Updated') + ' "{}"'.format(manager.unreleased.filename)
  678. print(colored(message, 'cyan'))
  679. else:
  680. if not manager.unreleased.entries:
  681. print('No entries in the unreleased changelog.')
  682. else:
  683. for component, entries in Stream.groupby(manager.unreleased.entries, lambda x: x.component):
  684. print(colored(component or 'No Component', 'yellow'))
  685. for entry in entries:
  686. lines = entry.description.splitlines()
  687. lines[1:] = [' ' * (len(entry.type) + 4) + x for x in lines[1:]]
  688. print(' {}: {}'.format(entry.type, '\n'.join(lines)))
  689. _entry_point = lambda: sys.exit(cli())
  690. if __name__ == '__main__':
  691. _entry_point()