Example #1
0
class ChangelogFormatCommand(BaseChangelogCommand):
    """ Format the changelog in the terminal or in Markdown format. """

    name = "changelog format"
    options = [
        option(
            "markdown",
            "m",
            description="Render the changelog in Markdown format.",
        ),
        option(
            "all",
            "a",
            description="Render all changelogs in reverse chronological order.",
        ),
    ]
    arguments = [
        argument("version", "The changelog version to format.", optional=True),
    ]

    def handle(self) -> int:
        if not self._validate_arguments():
            return 1

        if self.option("all"):
            changelogs = self.manager.all()
        elif (version := self.argument("version")):
            changelogs = [self.manager.version(version)]
            if not changelogs[0].exists():
                self.line_error(
                    f'error: Changelog for <opt>version</opt> "{version}" does not exist.',
                    'error')
                return 1
        else:
Example #2
0
class ChangelogConvertCommand(BaseChangelogCommand):
    """ Convert Slam's old YAML based changelogs to new style TOML changelogs.

  Sometimes the changelog entries in the old style would be suffixed with the
  author's username in the format of <code>@Name</code> or <code>(@Name)</code>, so this command will
  attempt to extract that information to figure out the author of the change.
  """

    name = "changelog convert"
    options = [
        option(
            "author",
            "a",
            description=
            "The author to fall back to. If not specified, the current VCS queried for the "
            "author name instead and their email will be used (depending on the normalization of the "
            "repository remote, this will be converted to a username, for example in the case of GitHub).",
            flag=False,
        ),
        option(
            "directory",
            "d",
            description=
            "The directory from which to load the old changelogs. Defaults to the same directory that the "
            "new changelogs will be written to.",
            flag=False,
        ),
        option("dry", description="Do not make changes on disk."),
        option(
            "fail-fast",
            "x",
            description=
            "If this flag is enabled, exit as soon as an error is encountered with any file.",
        ),
    ]

    CHANGELOG_TYPE_MAPPING_TABLE = {
        'change': 'improvement',
        'break': 'breaking change',
        'breaking_change': 'breaking change',
        'refactor': 'hygiene',
    }

    def handle(self) -> int:
        import yaml

        vcs = self.app.repository.vcs()
        author = self.option("author") or (vcs.get_author().email
                                           if vcs else None)

        if not author:
            self.line_error('error: missing <opt>--author,-a</opt>', 'error')
            return 1

        directory = self.option("directory") or self.manager.directory
        has_failures = False
        for filename in directory.iterdir():
            if has_failures and self.option("fail-fast"):
                break
            if filename.suffix in ('.yaml', '.yml'):
                try:
                    self._convert_changelog(author, filename)
                except yaml.error.YAMLError as exc:
                    has_failures = True
                    self.line_error(f'warn: cannot parse "{filename}": {exc}',
                                    'warning')
                    continue
                except Exception as exc:
                    has_failures = True
                    self.line_error(
                        f'warn: could not convert "{filename}": {exc}',
                        'warning')
                    if self.io.is_very_verbose:
                        import traceback
                        self.line_error(traceback.format_exc())
                    continue

        return 1 if has_failures else 0

    def _convert_changelog(self, default_author: str, source: Path) -> None:
        import datetime
        import databind.json
        import yaml

        data = yaml.safe_load(source.read_text())
        entries = []
        for original_entry in data['changes']:
            prefix = ''
            component = original_entry['component']
            if component == 'docs':
                change_type = 'docs'
            elif component in ('test', 'tests'):
                change_type = 'tests'
            else:
                change_type = original_entry['type']
                prefix = f'{component}: ' if component != 'general' else ''
            author, original_entry[
                'description'] = self._match_author_in_description(
                    original_entry['description'])
            new_entry = self.manager.make_entry(
                change_type=self.CHANGELOG_TYPE_MAPPING_TABLE.get(
                    change_type, change_type),
                description=prefix + original_entry['description'],
                author=author or default_author,
                pr=None,
                issues=original_entry.get('fixes', None) or None,
            )
            entries.append(new_entry)

        if source.stem == '_unreleased':
            dest = self.manager.unreleased()
        else:
            dest = self.manager.version(source.stem)

        changelog = dest.content if dest.exists() else Changelog()
        changelog.release_date = datetime.datetime.strptime(
            data['release_date'],
            '%Y-%m-%d').date() if data.get('release_date') else None
        changelog.entries = entries

        if self.option("dry"):
            self.io.write_line(
                f'<fg=cyan;options=underline># {dest.path}</fg>')
            print(toml_highlight(t.cast(dict, databind.json.dump(changelog))))
        else:
            dest.save(changelog)

    def _match_author_in_description(
            self, description: str) -> tuple[str | None, str]:
        """ Internal. Tries to find the @Author at the end of a changelog entry description. """

        import re
        match = re.search(r'(.*)\((@[\w\-_ ]+)\)$', description)
        return match.group(2) if match else None, match.group(
            1).strip() if match else description
Example #3
0
class ChangelogAddCommand(BaseChangelogCommand):
    """ Add an entry to the unreleased changelog via the CLI.

  A changelog is a TOML file, usually in the <u>.changelog/</u> directory, named with
  the version number it refers to and containing changelog entries. Changes that
  are currently not released in a version are stored in a file called
  <u>_unreleased.toml</u>.

  Changelog entries contain at least one author, a type (e.g. whether the entry
  describes a feature, enhancement, bug fix, etc.) and optionally a subject (e.g.
  whether the change is related to docs or a particular component of the code), a
  Markdown description, possibly a link to a pull request with which the change
  was introduced and links to issues that the changelog addresses.

  <b>Example:</b>

    <fg=blue># .changelog/0.1.1.toml</fg>
    <fg=cyan>[changelog]</fg>
    <fg=green>release-date</fg> = <fg=yellow>"2022-01-17"</fg>

    <fg=cyan>[[changelog.entries]]</fg>
    <fg=green>id</fg> = <fg=yellow>"a7bc01f"</fg>
    <fg=green>type</fg> = <fg=yellow>"improvement"</fg>
    <fg=green>description</fg> = <fg=yellow>"Improvement to `my_package.util`"</fg>
    <fg=green>author</fg> = <fg=yellow>"username"</fg>
    <fg=green>pr</fg> = <fg=yellow>"https://github.com/username/my_package/pulls/13"</fg>

  Changelog entries can be managed easily using the <info>slam log</info> command.

    <fg=yellow>$</fg> slam log add -t feature -d 'Improvement to `my_package.util`"

  The <fg=green>pr</fg> field is usually set manually after the PR is created or updated
  automatically by a CI action using the <info>slam log update-pr-field</info> command.
  """

    name = "changelog add"

    options = [
        option(
            "type",
            "t",
            description=
            f"The type of the changelog. Unless configured differently, one of {', '.join(DEFAULT_VALID_TYPES)}",
            flag=False,
        ),
        option(
            "description",
            "d",
            description=
            "A Markdown formatted description of the changelog entry.",
            flag=False,
        ),
        option(
            "author",
            "a",
            description=
            "Your username or email address. By default, this will be your configured Git name and email address.",
            flag=False,
        ),
        option(
            "pr",
            None,
            description=
            "The pull request that the change is introduced to the main branch with. This is not usually "
            "known at the time the changelog entry is created, so this option is not often used. If the remote "
            "repository is well supported by Slam, a pull request number may be specified and converted to a full "
            "URL by Slam, otherwise a full URL must be specified.",
            flag=False,
        ),
        option(
            "issue",
            "i",
            description=
            "An issue related to this changelog. If the remote repository is well supported by Slam, an issue "
            "number may be specified and converted to a full URL by Slam, otherwise a full URL must be specified.",
            flag=False,
            multiple=True,
        ),
        option(
            "commit",
            "c",
            description=
            "Commit the currently staged changes in the VCS as well as the updated changelog file to disk. The "
            "commit message is a concatenation of the <opt>--type, -t</opt> and <opt>--description, -d</opt>, as well as "
            "the directory relative to the VCS toplevel if the changelog is created not in the toplevel directory of the "
            "repository."),
    ]

    def handle(self) -> int:
        import databind.json

        if self.manager.readonly:
            self.line_error(
                f'error: cannot add changelog because the feature must be enabled in the config',
                'error')
            return 1

        vcs = self.app.repository.vcs()
        remote = self.app.repository.host()
        change_type: str | None = self.option("type")
        description: str | None = self.option("description")
        author: str | None = self.option("author") or (
            remote.get_username(self.app.repository)
            if remote else vcs.get_author().email if vcs else None)
        pr: str | None = self.option("pr")
        issues: list[str] | None = self.option("issue")

        if not vcs and self.option("commit"):
            self.line_error(
                'error: no VCS detected, but <opt>--commit, -c</opt> was used',
                'error')
            return 1

        if not change_type:
            self.line_error('error: missing <opt>--type,-t</opt>', 'error')
            return 1
        if not description:
            self.line_error('error: missing <opt>--description,-d</opt>',
                            'error')
            return 1
        if not author:
            self.line_error('error: missing <opt>--author,-a</opt>', 'error')
            return 1

        entry = self.manager.make_entry(change_type, description, author, pr,
                                        issues)
        unreleased = self.manager.unreleased()
        changelog = unreleased.content if unreleased.exists() else Changelog()
        changelog.entries.append(entry)
        unreleased.save(changelog)

        print(toml_highlight(t.cast(dict, databind.json.dump(entry))))

        if self.option("commit"):
            assert vcs is not None
            commit_message = f'{change_type}: {description}'
            main_project = self.app.main_project()
            relative = main_project.directory.relative_to(
                self.app.repository.directory) if main_project else Path('.')
            if relative != Path('.'):
                prefix = str(relative).replace("\\", "/").strip("/")
                commit_message = f'{prefix}/: {commit_message}'
            vcs.commit_files([unreleased.path], commit_message)

        return 0
Example #4
0
class ChangelogUpdatePrCommand(Command):
    """ Update the <u>pr</u> field of changelog entries in a commit range.

  Updates all changelog entries that were added in a given commit range. This is
  useful to run in CI for a pull request to avoid having to manually update the
  changelog entry after the PR has been created.
  """

    name = "changelog update-pr"
    arguments = [
        argument(
            "base_revision",
            description=
            "The revision ID to look back to to make out which changelog entries have been added since.",
            optional=True,
        ),
        argument(
            "pr",
            description=
            "The reference to the PR that should be inserted into all entries added between the specified "
            "revision and the current version of the unreleased changelog.",
            optional=True,
        )
    ]
    options = [
        option(
            "dry",
            "d",
            description="Do not actually make changes on disk.",
        ),
        option(
            "overwrite",
            description=
            "Update PR references even if an entry's reference is already set but different.",
        ),
        option(
            "commit",
            "c",
            description="Commit the changes, if any.",
        ),
        option(
            "push",
            "p",
            description="Push the changes, if any.",
        ),
        option(
            "name",
            description=
            "Override the <code>user.name</code> Git option (only with <opt>--commit, -c</opt>)",
            flag=False,
        ),
        option(
            "email",
            description=
            "Override the <code>user.email</code> Git option (only with <opt>--commit, -c</opt>).",
            flag=False,
        ),
        option(
            "use",
            description=
            "Use the specified plugin to publish the updated changelogs. Use this in supported CI environments "
            "instead of manually configuring the command-line settings.",
            flag=False,
        ),
        option(
            "list",
            "l",
            description=
            "List the available plugins you can pass to the <opt>--use</opt> option.",
        )
    ]

    def __init__(self, app: Application):
        super().__init__()
        self.app = app
        self.managers = {
            project: get_changelog_manager(app.repository, project)
            for project in app.repository.projects()
        }

    def handle(self) -> int:
        from nr.util.plugins import iter_entrypoints, load_entrypoint

        if not self._validate_arguments():
            return 1

        if self.option("list"):
            for ep in iter_entrypoints(
                    ChangelogUpdateAutomationPlugin.ENTRYPOINT):
                self.line(f'  • {ep.name}')
            return 0

        automation_plugin: ChangelogUpdateAutomationPlugin | None = None
        if plugin_name := self.option("use"):
            logger.info(
                'Loading changelog update automation plugin <subj>%s</subj>',
                plugin_name)
            automation_plugin = load_entrypoint(
                ChangelogUpdateAutomationPlugin,
                plugin_name)()  # type: ignore[misc]
            automation_plugin.io = self.io
            automation_plugin.initialize()
            base_revision: str = automation_plugin.get_base_ref()
        else:
Example #5
0
class LinkCommandPlugin(Command, ApplicationPlugin):
    """
  Symlink your Python package with the help of Flit.

  This command uses <u>Flit [0]</u> to symlink the Python package you are currently
  working on into your Python environment's site-packages. This is particulary
  useful if your project is using a <u>PEP 517 [1]</u> compatible build system that does
  not support editable installs.

  When you run this command, the <u>pyproject.toml</u> will be temporarily rewritten such
  that Flit can understand it. The following ways to describe a Python project are
  currently supported be the rewriter:

  1. <u>Poetry [2]</u>

    Supported configurations:
      - <fg=cyan>version</fg>
      - <fg=cyan>plugins</fg> (aka. "entrypoints")
      - <fg=cyan>scripts</fg>

  2. <u>Flit [0]</u>

    <i>Since the <opt>link</opt> command relies on Flit, no subset of configuration neeeds to be
    explicitly supported.</i>

  <b>Example usage:</b>

    <fg=yellow>$</fg> slam link
    <fg=dark_gray>Discovered modules in /projects/my_package/src: my_package
    Extras to install for deps 'all': {{'.none'}}
    Symlinking src/my_package -> .venv/lib/python3.10/site-packages/my_package</fg>

  <b>Important notes:</b>

    This command will <b>symlink</b> your package into your Python environment; this is
    much unlike a Pip editable install which instead points to your code via a
    <code>.pth</code> file. If you install something into your environment that requires an
    older version of the package you symlinked, Pip may write into those symlinked
    files and effectively change your codebase, which could lead to potential loss
    of changes.

  <u>[0]: https://flit.readthedocs.io/en/latest/</u>
  <u>[1]: https://www.python.org/dev/peps/pep-0517/</u>
  <u>[2]: https://python-poetry.org/</u>
  """

    app: Application

    name = "link"
    help = textwrap.dedent(__doc__)
    options = [
        option(
            "python",
            "p",
            description="The Python executable to link the package to.",
            flag=False,
            default=os.getenv('PYTHON', 'python'),
        ),
        option(
            "dump-pyproject",
            description=
            "Dump the updated pyproject.toml and do not actually do the linking.",
        ), venv_check_option
    ]

    def load_configuration(self, app: Application) -> None:
        return None

    def activate(self, app: Application, config: None):
        self.app = app
        app.cleo.add(self)

    def _get_source_directory(self) -> Path:
        directory = Path.cwd()
        if (src_dir := directory / 'src').is_dir():
            directory = src_dir
        return directory
Example #6
0
class CheckCommandPlugin(Command, ApplicationPlugin):
  """ Run sanity checks on your Python project. """

  app: Application
  config: dict[Project, CheckConfig]

  name = "check"
  options = [
    option(
      "show-skipped",
      description="Show skipped checks.",
    ),
    option(
      "warnings-as-errors", "w",
      description="Treat warnings as errors.",
    )
  ]

  def load_configuration(self, app: 'Application') -> dict[Project, CheckConfig]:
    import databind.json
    result = {}
    for project in app.repository.projects():
      config = databind.json.load(project.raw_config().get('check', {}), CheckConfig)
      result[project] = config
    return result

  def activate(self, app: 'Application', config: dict[Project, CheckConfig]) -> None:
    self.app = app
    self.config = config
    app.cleo.add(self)

  def handle(self) -> int:

    counter: t.MutableMapping[CheckResult, int] = collections.defaultdict(int)
    if self.app.repository.is_monorepo:
      for check in self._run_application_checks():
        counter[check.result] += 1
    for project in self.app.repository.projects():
      if not project.is_python_project: continue
      for check in self._run_project_checks(project):
        counter[check.result] += 1

    if self.option("warnings-as-errors") and counter.get(Check.WARNING, 0) > 0:
      exit_code = 1
    elif counter.get(Check.ERROR, 0) > 0:
      exit_code = 1
    else:
      exit_code = 0

    self.line(f'Summary: ' + ', '.join(f'{count} <fg={COLORS[result]};options=bold>{result.name}</fg>'
      for result, count in sorted(counter.items())) + f', exit code: {exit_code}')

    return exit_code

  def _print_checks(self, checks: t.Sequence[Check]) -> None:
    max_w = max(len(c.name) for c in checks)
    for check in checks:

      if not self.option("show-skipped") and check.result == Check.SKIPPED:
        continue

      color = COLORS[check.result]
      self.io.write(f'  <b>{check.name.ljust(max_w)}</b>  <fg={color};options=bold>{check.result.name.ljust(14)}</fg>')
      if check.description:
        self.io.write(f' — {check.description}')
      self.io.write('\n')

      if check.details:
        for line in check.details.splitlines():
          self.io.write_line(f'    {line}')

  def _run_project_checks(self, project: Project) -> t.Iterator[Check]:
    checks = []
    for plugin_name in sorted(self.config[project].plugins):
      plugin = load_entrypoint(CheckPlugin, plugin_name)()
      try:
        for check in sorted(plugin.get_project_checks(project), key=lambda c: c.name):
          check.name = f'{plugin_name}:{check.name}'
          yield check
          checks.append(check)
      except Exception as exc:
        logger.exception(
          'Uncaught exception in project <subj>%s</subj> application checks for plugin <val>%s</val>',
          project, plugin_name
        )
        check = Check(f'{plugin_name}', CheckResult.ERROR, str(exc))
        yield check
        checks.append(check)
      if not self.app.repository.is_monorepo:
        try:
          for check in sorted(plugin.get_application_checks(self.app), key=lambda c: c.name):
            check.name = f'{plugin_name}:{check.name}'
            yield check
            checks.append(check)
        except Exception as exc:
          logger.exception('Uncaught exception in application checks for plugin <val>%s</val>', plugin_name)
          check = Check(f'{plugin_name}', CheckResult.ERROR, str(exc))
          yield check
          checks.append(check)

    if checks:
      if self.app.repository.is_monorepo:
        self.line(f'Checks for project <info>{project.id}</info>')
        self.line('')
      self._print_checks(checks)
      self.line('')

  def _run_application_checks(self) -> t.Iterable[Check]:
    plugin_names = {p for project in self.app.repository.projects() for p in self.config[project].plugins}
    checks = []
    for plugin_name in sorted(plugin_names):
      plugin = load_entrypoint(CheckPlugin, plugin_name)()
      for check in sorted(plugin.get_application_checks(self.app), key=lambda c: c.name):
        check.name = f'{plugin_name}:{check.name}'
        yield check
        checks.append(check)

    if checks:
      self.line(f'Global checks:')
      self._print_checks(checks)
      self.line('')
Example #7
0
class InstallCommandPlugin(Command, ApplicationPlugin):
    """ Install your project and its dependencies via Pip. """

    app: Application
    name = "install"
    options = [
        option(
            "only",
            description=
            "Path to the subproject to install only. May still cause other projects to be installed if "
            "required by the selected project via inter dependencies, but only their run dependencies will be installed.",
            flag=False,
        ),
        option(
            "link",
            description=
            "Symlink the root project using <opt>slam link</opt> instead of installing it directly.",
        ),
        option(
            "no-dev",
            description="Do not install development dependencies.",
        ),
        option(
            "no-root",
            description=
            "Do not install the package itself, but only its dependencies.",
        ),
        option(
            "extras",
            description=
            "A comma-separated list of extras to install. Note that <s>\"dev\"</s> is a valid extras.",
            flag=False,
        ),
        option(
            "only-extras",
            description=
            "Install only the specified extras. Note that <s>\"dev\"</s> is a valid extras.",
            flag=False,
        ),
        venv_check_option,
        option(
            "python",
            "p",
            description="The Python executable to install to.",
            flag=False,
            default=os.getenv('PYTHON', 'python'),
        ),
    ]

    def load_configuration(self, app: Application) -> None:
        return None

    def activate(self, app: Application, config: None) -> None:
        self.app = app
        app.cleo.add(self)

    def handle(self) -> int:
        for a, b in [("only-extras", "extras"), ("no-root", "link"),
                     ("only-extras", "link")]:
            if self.option(a) and self.option(b):
                self.line_error(
                    f'error: conflicting options <opt>--{a}</opt> and <opt>--{b}</opt>',
                    'error')
                return 1

        if not venv_check(self):
            return 1

        if only_project := self.option("only"):
            project_path = Path(only_project).resolve()
            projects = [
                p for p in self.app.repository.projects()
                if p.directory.resolve() == project_path
            ]
            if not projects:
                self.line_error(
                    f'error: "{only_project}" does not point to a project',
                    'error')
                return 1
            assert len(projects) == 1, projects
            project_dependencies = self._get_project_dependencies(projects[0])
        else:
Example #8
0
import logging
import os
import shlex
from pathlib import Path
import subprocess as sp

from slam.application import Application, Command, option
from slam.plugins import ApplicationPlugin
from slam.project import Project
from slam.util.python import Environment

logger = logging.getLogger(__name__)
venv_check_option = option(
    "--no-venv-check",
    description=
    "Do not check if the target Python environment is a virtual environment.",
)


def venv_check(cmd: Command, message='refusing to install') -> bool:
    if not cmd.option("no-venv-check"):
        env = Environment.of(cmd.option("python"))
        if not env.is_venv():
            cmd.line_error(
                f'error: {message} because you are not in a virtual environment',
                'error')
            cmd.line_error(
                '       enter a virtual environment or use <opt>--no-venv-check</opt>',
                'error')
            return False
    return True
Example #9
0
class TestCommandPlugin(Command, ApplicationPlugin):
    """
  Execute commands configured in <fg=green>[tool.slam.test]</fg>.

  <b>Example configuration:</b>

    <fg=cyan>[tool.slam.test]</fg>
    <fg=green>pytest</fg> = <fg=yellow>"pytest --cov=my_package tests/"</fg>
    <fg=green>mypy</fg> = <fg=yellow>"mypy src"</fg>

  <b>Example usage:</b>

    <fg=yellow>$</fg> slam test
    <fg=dark_gray>mypy | Success: no issues found in 12 source files
    pytest | ===================================== test session starts ======================================
    pytest | platform linux -- Python 3.10.2, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
    ...</fg>
  """

    app: Application
    tests: list[Test]

    name = "test"
    arguments = [
        argument("test",
                 "One or more tests to run (runs all if none are specified)",
                 optional=True,
                 multiple=True),
    ]
    options = [
        option(
            "no-line-prefix", "s",
            "Do not prefix output from the test commands with the test name (default if "
            "a single argument for <info>test</info> is specified)."),
    ]
    options[
        0]._default = NotSet.Value  # Hack to set a default value for the flag

    def load_configuration(self, app: Application) -> None:
        self.app = app
        self.tests = []
        for project in app.repository.projects():
            for test_name, command in project.raw_config().get('test',
                                                               {}).items():
                self.tests.append(Test(project, test_name, command))

    def activate(self, app: Application, config: None) -> None:
        app.cleo.add(self)

    def _select_tests(self, name: str) -> set[Test]:
        result = set()
        for test in self.tests:
            use_test = (self.app.repository.is_monorepo and
                        (name == test.id or
                         (name.startswith(':') and test.name == name[1:]) or
                         (test.project.id == name))
                        or not self.app.repository.is_monorepo and
                        (name == test.name))
            if use_test:
                result.add(test)
        if not result:
            raise ValueError(f'{name!r} did not match any tests')
        return result

    def handle(self) -> int:
        if not self.tests:
            self.line_error('error: no tests configured', 'error')
            return 1

        test_names: list[str] = self.argument("test")

        if not test_names:
            tests = set(self.tests)
        else:
            try:
                tests = {t for a in test_names for t in self._select_tests(a)}
            except ValueError as exc:
                self.line_error(f'error: {exc}', 'error')
                return 1

        if (no_line_prefix := self.option("no-line-prefix")) is NotSet.Value:
            no_line_prefix = (test_names is not None and len(tests) == 1)

        single_project = len(set(t.project for t in self.tests)) == 1

        results = {}
        for test in sorted(tests, key=lambda t: t.id):
            results[test.name if single_project else test.id] = TestRunner(
                test.name if single_project else test.id, test.command,
                self.io, test.project.directory, not no_line_prefix).run()

        if len(tests) > 1:
            self.line('\n<comment>test summary:</comment>')
            for test_name, exit_code in results.items():
                color = 'green' if exit_code == 0 else 'red'
                self.line(
                    f'  <fg={color}>•</fg> {test_name} (exit code: {exit_code})'
                )

        return 0 if set(results.values()) == {0} else 1
Example #10
0
class PublishCommandPlugin(Command, ApplicationPlugin):
    """ A wrapper to publish the Python project to a repository such as PyPI.

  Uses the PEP 517 build system defined in the <code>pyproject.toml</code> to build
  packages and then uploads them with Twine. Note that it currently expects the build
  backend to be installed already.

  The command-line options are almost identical to the <code>twine upload</code> command.
  """

    app: Application

    name = "publish"
    options = [
        option("repository", "r", flag=False, default='pypi'),
        option("repository-url", flag=False),
        option("sign", "s"),
        option("sign-with", flag=False),
        option("identity", "i", flag=False),
        option("username", "u", flag=False),
        option("password", "p", flag=False),
        option("non-interactive"),
        option("comment", "c", flag=False),
        option("config-file", flag=False, default="~/.pypirc"),
        option("skip-existing"),
        option("cert", flag=False),
        option("client-cert", flag=False),
        #option("verbose"),
        option("disable-progress-bar"),
        option("dry", "d"),
    ]

    def load_configuration(self, app: Application) -> None:
        return None

    def activate(self, app: Application, config: None) -> None:
        self.app = app
        return app.cleo.add(self)

    def handle(self) -> int:
        from twine.settings import Settings
        from twine.commands.upload import upload

        distributions: list[Path] = []

        with tempfile.TemporaryDirectory() as tmpdir:
            for project in self.app.repository.projects():
                if not project.is_python_project: continue

                self.line(f'Build <info>{project.dist_name()}</info>')
                backend = Pep517BuildBackend(
                    project.pyproject_toml.value()['build-system']
                    ['build-backend'], project.directory, Path(tmpdir))

                sdist = backend.build_sdist()
                self.line(f'  <comment>{sdist.name}</comment>')
                wheel = backend.build_wheel()
                self.line(f'  <comment>{wheel.name}</comment>')

                distributions += [sdist, wheel]

            if not self.option("dry"):
                kwargs = {
                    option.name.replace('-', '_'): self.option(option.name)
                    for option in self.options
                }
                kwargs['repository_name'] = kwargs.pop('repository')
                settings = Settings(**kwargs)
                upload(settings, [str(d) for d in distributions])

        return 0
Example #11
0
class InitCommandPlugin(ApplicationPlugin, Command):
    """ Bootstrap some files for a Python project.

  Currently available templates:

  1. <info>poetry</info>
  """

    app: Application

    name = "init"
    arguments = [
        argument(
            "directory",
            description=
            "The directory in which to create the generated files. If not specified, a new directory with "
            "the name specified via the <opt>--name</opt> option is created.",
            optional=True,
        )
    ]
    options = [
        option(
            "--name",
            description="The name of the Python package.",
            flag=False,
        ),
        option(
            "--license",
            description="The package license.",
            flag=False,
            default="MIT",
        ),
        option(
            "--template",
            "-t",
            description="The template to use.",
            flag=False,
            default="poetry",
        ),
        option(
            "--overwrite",
            "-f",
            description="Overwrite files.",
        ),
        option(
            "--dry",
            "-d",
            description="Dont actually write files.",
        ),
        option(
            "--as-markdown",
            description=
            "Render the content as Markdown (uses by the Slam docs)",
        ),
    ]

    def load_configuration(self, app: Application) -> None:
        return None

    def activate(self, app: Application, config: None) -> None:
        self.app = app
        app.cleo.add(self)

    def handle(self) -> int:
        if not self.option("name"):
            self.line_error('error: <opt>--name</opt> is required', 'error')
            return 1

        template = self.option("template")
        if template not in TEMPLATES:
            self.line_error(f'error: template "{template}" does not exist',
                            'error')
            return 1

        vcs = self.app.repository.vcs()
        author = vcs.get_author() if vcs else None
        directory = Path(
            self.argument("directory")
            or self.option("name").replace('.', '-'))

        scope = {
            'name':
            self.option("name"),
            'path':
            self.option("name").replace('.', '/').replace('-', '_'),
            'package':
            self.option("name").replace('.', '_').replace('-', '_'),
            'license':
            self.option("license"),
            'year':
            datetime.date.today().year,
            'author_name':
            author.name if author and author.name else 'Unknown',
            'author_email':
            author.email if author and author.email else '*****@*****.**',
        }
        for filename, content in TEMPLATES[template].items():
            if filename == 'LICENSE':
                content = get_license_metadata(
                    self.option("license")).license_text
                content = wrap_license_text(content)
                content = 'Copyright (c) {year} {author_name}\n\n'.format(
                    **scope) + content
                content = f'The {self.option("license")} License\n\n' + content
            else:
                filename = filename.format(**scope)
                content = textwrap.dedent(content.format(**scope)).strip()
                if content:
                    content += '\n'

            path = directory / filename

            if self.option("as-markdown"):
                print(f'=== "{path}"\n')
                print(f'    ```{path.suffix[1:]}')
                print(textwrap.indent(content, '    '))
                print(f'    ```\n')
                continue

            if path.exists() and not self.option("overwrite"):
                self.line(f'skip <info>{path}</info> (already exists)')
                continue

            if not self.option("dry") and not self.option("as-markdown"):
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(content)

            self.line(f'write <info>{path}</info>')

        return 0