diff --git a/docs/source/conf.py b/docs/source/conf.py index 497b5e8d..2906fe38 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,7 +37,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "myst_parser", - "sphinx_click", + "sphinxarg.ext", ] try: @@ -49,6 +49,9 @@ myst_enable_extensions = ["html_image", "tasklist"] +# Allow argparse directive in MyST markdown +myst_fence_as_directive = ["argparse"] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/source/reference/releaser_cli.md b/docs/source/reference/releaser_cli.md index bbca2b73..3a4c3c91 100644 --- a/docs/source/reference/releaser_cli.md +++ b/docs/source/reference/releaser_cli.md @@ -1,6 +1,9 @@ # Releaser CLI -```{click} jupyter_releaser.cli:main +The `jupyter-releaser` command-line interface provides tools for managing releases. + +```{argparse} +:module: jupyter_releaser.cli +:func: create_parser :prog: jupyter-releaser -:nested: full ``` diff --git a/jupyter_releaser/cli.py b/jupyter_releaser/cli.py index 6e48db6a..58d81511 100644 --- a/jupyter_releaser/cli.py +++ b/jupyter_releaser/cli.py @@ -1,475 +1,530 @@ """CLI for Jupyter Releaser.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import argparse import os import os.path as osp +import sys import typing as t from glob import glob from pathlib import Path -import click - from jupyter_releaser import changelog, lib, npm, python, util - -class ReleaseHelperGroup(click.Group): - """Click group tailored to jupyter-releaser""" - - _needs_checkout_dir: t.Dict[str, bool] = {} # noqa: RUF012 - - def invoke(self, ctx): - """Handle jupyter-releaser config while invoking a command""" - # Get the command name and make sure it is valid - cmd_name = ctx.protected_args[0] - if cmd_name not in self.commands or "--help" in ctx.args: - super().invoke(ctx) - - if cmd_name == "list-envvars": - envvars: t.Dict[str, str] = {} - for cmd_name in self.commands: - for param in self.commands[cmd_name].params: - if isinstance(param, click.Option) and param.envvar: - envvars[str(param.name)] = str(param.envvar) - - for key in sorted(envvars): - util.log(f"{key.replace('_', '-')}: {envvars[key]}") - - return - - orig_dir = os.getcwd() - - if cmd_name.replace("-", "_") in self._needs_checkout_dir: - if not osp.exists(util.CHECKOUT_NAME): - msg = "Please run prep-git first" - raise ValueError(msg) - os.chdir(util.CHECKOUT_NAME) - - # Read in the config - config = util.read_config() - hooks = config.get("hooks", {}) - options = config.get("options", {}) - skip = config.get("skip", []) - - if "--force" in ctx.args: - skip = [] - ctx.args.remove("--force") - - skip += os.environ.get("RH_STEPS_TO_SKIP", "").split(",") - - # Print a separation header - util.log(f'\n\n{"-" * 50}') - util.log(f"\n\n{cmd_name}\n\n") - util.log(f'\n\n{"-" * 50}') - - if cmd_name in skip or cmd_name.replace("-", "_") in skip: - util.log("*** Skipping based on skip config") - util.log(f'{"-" * 50}\n\n') - return - - # Handle all of the parameters - for param in self.commands[cmd_name].get_params(ctx): - name = param.name - assert name is not None - - # Defer to env var overrides - if param.envvar and os.environ.get(str(param.envvar)): - value = os.environ[str(param.envvar)] - if "token" in name.lower(): - value = "***" - util.log(f"Using env value for {name}: '{value}'") - continue - - # Handle cli and options overrides. - if name in options or name.replace("_", "-") in options: - arg = f"--{name.replace('_', '-')}" - # Defer to cli overrides - if arg not in ctx.args: - val = options.get(name, options.get(name.replace("_", "-"))) - if "token" in arg.lower(): - val = "***" - util.log(f"Adding option override for {arg}: '{val}") - if isinstance(val, list): - for v in val: - ctx.args.append(arg) - ctx.args.append(v) - else: - ctx.args.append(arg) - ctx.args.append(val) - continue - val = ctx.args[ctx.args.index(arg) + 1] - if "token" in name.lower(): - val = "***" - util.log(f"Using cli arg for {name}: '{val}'") - continue - - util.log(f"Using default value for {name}: '{param.default}'") - - util.log(f'{"~" * 50}\n\n') - - # Handle before hooks - before = f"before-{cmd_name}" - if before in hooks: - before_hooks = hooks[before] - if isinstance(before_hooks, str): - before_hooks = [before_hooks] - if before_hooks: - util.log(f"\nRunning hooks for {before}") - for hook in before_hooks: - util.run(hook) - - # Run the actual command - super().invoke(ctx) - - # Handle after hooks - - # Re-read config if we just did a git checkout - if cmd_name in ["prep-git", "extract-release"]: - os.chdir(util.CHECKOUT_NAME) - config = util.read_config() - hooks = config.get("hooks", {}) - - after = f"after-{cmd_name}" - if after in hooks: - after_hooks = hooks[after] - if isinstance(after_hooks, str): - after_hooks = [after_hooks] - if after_hooks: - util.log(f"\nRunning hooks for {after}") - for hook in after_hooks: - util.run(hook) - - os.chdir(orig_dir) - - def list_commands(self, ctx): # noqa: ARG002 - """List commands in insertion order""" - return self.commands.keys() - - -@click.group(cls=ReleaseHelperGroup) -@click.option("--force", default=False, help="Force a command to run even when skipped by config") -def main(force): # noqa: ARG001 - """Jupyter Releaser scripts""" - - -# Extracted common options -version_spec_options: t.Any = [ - click.option( - "--version-spec", +# Registry for commands that need to use checkout directory +_needs_checkout_dir: t.Set[str] = set() + + +def use_checkout_dir(func): + """Decorator to mark a command as needing the checkout directory""" + _needs_checkout_dir.add(func.__name__.replace("_", "-").replace("cmd-", "")) + return func + + +class OptionDef: + """Definition of a CLI option""" + + def __init__( + self, + name: str, + envvar: t.Optional[str] = None, + default: t.Any = None, + help_text: str = "", + is_flag: bool = False, + multiple: bool = False, + nargs: t.Optional[str] = None, + ): + """Initialize an OptionDef instance.""" + self.name = name + self.envvar = envvar + self.default = default + self.help_text = help_text + self.is_flag = is_flag + self.multiple = multiple + self.nargs = nargs + + +# Define all the common options +VERSION_SPEC_OPTIONS = [ + OptionDef( + "version-spec", envvar="RH_VERSION_SPEC", default="", - help="The new version specifier", + help_text="The new version specifier", ) ] - -post_version_spec_options: t.Any = [ - click.option( - "--post-version-spec", +POST_VERSION_SPEC_OPTIONS = [ + OptionDef( + "post-version-spec", envvar="RH_POST_VERSION_SPEC", default="", - help="The post release version (usually dev)", + help_text="The post release version (usually dev)", ), - click.option( - "--post-version-message", - default="Bumped version to {post_version}", + OptionDef( + "post-version-message", envvar="RH_POST_VERSION_MESSAGE", - help="The post release message", + default="Bumped version to {post_version}", + help_text="The post release message", ), ] -version_cmd_options: t.Any = [ - click.option("--version-cmd", envvar="RH_VERSION_COMMAND", help="The version command") +VERSION_CMD_OPTIONS = [ + OptionDef("version-cmd", envvar="RH_VERSION_COMMAND", help_text="The version command") ] -repo_options: t.Any = [ - click.option("--repo", envvar="RH_REPOSITORY", help="The git repo"), +REPO_OPTIONS = [ + OptionDef("repo", envvar="RH_REPOSITORY", help_text="The git repo"), ] -branch_options: t.Any = [ # noqa: RUF005 - click.option("--ref", envvar="RH_REF", help="The source reference"), - click.option("--branch", envvar="RH_BRANCH", help="The target branch"), -] + repo_options - -auth_options: t.Any = [ - click.option("--auth", envvar="GITHUB_ACCESS_TOKEN", help="The GitHub auth token"), +BRANCH_OPTIONS = [ + OptionDef("ref", envvar="RH_REF", help_text="The source reference"), + OptionDef("branch", envvar="RH_BRANCH", help_text="The target branch"), + *REPO_OPTIONS, ] -username_options: t.Any = [ - click.option("--username", envvar="GITHUB_ACTOR", help="The git username") +AUTH_OPTIONS = [ + OptionDef("auth", envvar="GITHUB_ACCESS_TOKEN", help_text="The GitHub auth token"), ] -dist_dir_options: t.Any = [ - click.option( - "--dist-dir", +USERNAME_OPTIONS = [OptionDef("username", envvar="GITHUB_ACTOR", help_text="The git username")] + +DIST_DIR_OPTIONS = [ + OptionDef( + "dist-dir", envvar="RH_DIST_DIR", default="dist", - help="The folder to use for dist files", + help_text="The folder to use for dist files", ) ] -python_packages_options: t.Any = [ - click.option( - "--python-packages", +PYTHON_PACKAGES_OPTIONS = [ + OptionDef( + "python-packages", envvar="RH_PYTHON_PACKAGES", default=["."], multiple=True, - help='The list of strings of the form "path_to_package:name_of_package"', + help_text='The list of strings of the form "path_to_package:name_of_package"', ) ] -check_imports_options: t.Any = [ - click.option( - "--check-imports", +CHECK_IMPORTS_OPTIONS = [ + OptionDef( + "check-imports", envvar="RH_CHECK_IMPORTS", default=[], multiple=True, - help="The Python packages import to check for; default to the Python package name.", + help_text="The Python packages import to check for; default to the Python package name.", ) ] -dry_run_options: t.Any = [ - click.option("--dry-run", is_flag=True, envvar="RH_DRY_RUN", help="Run as a dry run") +DRY_RUN_OPTIONS = [ + OptionDef("dry-run", is_flag=True, envvar="RH_DRY_RUN", help_text="Run as a dry run") ] +GIT_URL_OPTIONS = [OptionDef("git-url", help_text="A custom url for the git repository")] -git_url_options: t.Any = [click.option("--git-url", help="A custom url for the git repository")] - - -release_url_options: t.Any = [ - click.option("--release-url", envvar="RH_RELEASE_URL", help="A draft GitHub release url") +RELEASE_URL_OPTIONS = [ + OptionDef("release-url", envvar="RH_RELEASE_URL", help_text="A draft GitHub release url") ] - -changelog_path_options: t.Any = [ - click.option( - "--changelog-path", +CHANGELOG_PATH_OPTIONS = [ + OptionDef( + "changelog-path", envvar="RH_CHANGELOG", default="CHANGELOG.md", - help="The path to changelog file", + help_text="The path to changelog file", ), ] -silent_option: t.Any = [ - click.option( - "--silent", envvar="RH_SILENT", default=False, help="Set a placeholder in the changelog." +SILENT_OPTIONS = [ + OptionDef( + "silent", + envvar="RH_SILENT", + default=False, + is_flag=True, + help_text="Set a placeholder in the changelog.", ) ] -since_options: t.Any = [ - click.option( - "--since", +SINCE_OPTIONS = [ + OptionDef( + "since", envvar="RH_SINCE", default=None, - help="Use PRs with activity since this date or git reference", + help_text="Use PRs with activity since this date or git reference", ), - click.option( - "--since-last-stable", + OptionDef( + "since-last-stable", is_flag=True, envvar="RH_SINCE_LAST_STABLE", - help="Use PRs with activity since the last stable git tag", + help_text="Use PRs with activity since the last stable git tag", ), ] -changelog_options: t.Any = ( - branch_options - + auth_options - + changelog_path_options - + since_options +CHANGELOG_OPTIONS = ( + BRANCH_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + SINCE_OPTIONS + [ - click.option( - "--resolve-backports", + OptionDef( + "resolve-backports", envvar="RH_RESOLVE_BACKPORTS", default=True, - help="Resolve backport PRs to their originals", + help_text="Resolve backport PRs to their originals", ), ] ) -npm_install_options: t.Any = [ - click.option( - "--npm-install-options", +NPM_INSTALL_OPTIONS = [ + OptionDef( + "npm-install-options", envvar="RH_NPM_INSTALL_OPTIONS", default="", - help="Options to pass when calling npm install", + help_text="Options to pass when calling npm install", ) ] -pydist_check_options: t.Any = [ - click.option( - "--pydist-check-cmd", +PYDIST_CHECK_OPTIONS = [ + OptionDef( + "pydist-check-cmd", envvar="RH_PYDIST_CHECK_CMD", default="pipx run twine check --strict {dist_file}", - help="The command to use to check a python distribution file", + help_text="The command to use to check a python distribution file", ), - click.option( - "--pydist-extra-check-cmds", + OptionDef( + "pydist-extra-check-cmds", envvar="RH_EXTRA_PYDIST_CHECK_CMDS", default=[ "pipx run 'validate-pyproject[all]' pyproject.toml", "pipx run check-wheel-contents --config pyproject.toml {dist_dir}", ], multiple=True, - help="Extra checks to run against the pydist file", + help_text="Extra checks to run against the pydist file", ), - click.option( - "--pydist-resource-paths", + OptionDef( + "pydist-resource-paths", envvar="RH_PYDIST_RESOURCE_PATHS", multiple=True, - help="Resource paths that should be available when installed", + help_text="Resource paths that should be available when installed", ), ] -tag_format_options: t.Any = [ - click.option( - "--tag-format", +TAG_FORMAT_OPTIONS = [ + OptionDef( + "tag-format", envvar="RH_TAG_FORMAT", default="v{version}", - help="The format to use for the release tag", + help_text="The format to use for the release tag", ) ] -def add_options(options): - """Add extracted common options to a click command""" +def add_option_to_parser(parser: argparse.ArgumentParser, opt: OptionDef): + """Add an option definition to an argparse parser""" + arg_name = f"--{opt.name}" + kwargs: t.Dict[str, t.Any] = {} + + if opt.help_text: + kwargs["help"] = opt.help_text + + if opt.is_flag: + kwargs["action"] = "store_true" + kwargs["default"] = False + elif opt.multiple: + kwargs["action"] = "append" + kwargs["default"] = None # We'll handle defaults later + else: + kwargs["default"] = None # We'll handle defaults later + + parser.add_argument(arg_name, **kwargs) + + +def add_options_to_parser(parser: argparse.ArgumentParser, options: t.List[OptionDef]): + """Add multiple option definitions to a parser""" + for opt in options: + add_option_to_parser(parser, opt) + + +def get_option_value( + args: argparse.Namespace, + opt: OptionDef, + config_options: t.Dict[str, t.Any], +) -> t.Any: + """Get the value for an option considering CLI, env vars, config, and defaults""" + name = opt.name.replace("-", "_") + cli_value = getattr(args, name, None) + + # Check environment variable first + if opt.envvar and os.environ.get(opt.envvar): + env_value = os.environ[opt.envvar] + display_value = "***" if "token" in name.lower() else env_value + util.log(f"Using env value for {name}: '{display_value}'") + if opt.is_flag: + return env_value.lower() in ("true", "1", "yes") + if opt.multiple: + return [env_value] # Environment variable is a single value for multiple + return env_value + + # Check CLI value + if cli_value is not None: + display_value = "***" if "token" in name.lower() else cli_value + util.log(f"Using cli arg for {name}: '{display_value}'") + return cli_value + + # Check config options + config_name = opt.name.replace("_", "-") + config_name_underscore = opt.name.replace("-", "_") + if config_name in config_options or config_name_underscore in config_options: + val = config_options.get(config_name, config_options.get(config_name_underscore)) + display_value = "***" if "token" in name.lower() else val + util.log(f"Adding option override for --{opt.name}: '{display_value}'") + return val + + # Use default + util.log(f"Using default value for {name}: '{opt.default}'") + return opt.default + + +def collect_all_options() -> t.Dict[str, OptionDef]: + """Collect all option definitions into a dict keyed by name""" + all_opts: t.Dict[str, OptionDef] = {} + + # Base option lists + option_lists = [ + VERSION_SPEC_OPTIONS, + POST_VERSION_SPEC_OPTIONS, + VERSION_CMD_OPTIONS, + REPO_OPTIONS, + BRANCH_OPTIONS, + AUTH_OPTIONS, + USERNAME_OPTIONS, + DIST_DIR_OPTIONS, + PYTHON_PACKAGES_OPTIONS, + CHECK_IMPORTS_OPTIONS, + DRY_RUN_OPTIONS, + GIT_URL_OPTIONS, + RELEASE_URL_OPTIONS, + CHANGELOG_PATH_OPTIONS, + SILENT_OPTIONS, + SINCE_OPTIONS, + CHANGELOG_OPTIONS, + NPM_INSTALL_OPTIONS, + PYDIST_CHECK_OPTIONS, + TAG_FORMAT_OPTIONS, + ] - # https://stackoverflow.com/a/40195800 - def _add_options(func): - for option in reversed(options): - func = option(func) - return func + # Command-specific options that have envvars + command_specific_options = [ + OptionDef( + "release-message", + envvar="RH_RELEASE_MESSAGE", + default="Publish {version}", + help_text="The message to use for the release commit", + ), + OptionDef( + "tag-message", + envvar="RH_TAG_MESSAGE", + default="Release {tag_name}", + help_text="The message to use for the release tag", + ), + OptionDef("npm-token", envvar="NPM_TOKEN", help_text="A token for the npm release"), + OptionDef( + "npm-cmd", + envvar="RH_NPM_COMMAND", + default="npm publish", + help_text="The command to run for npm release", + ), + OptionDef( + "twine-cmd", + envvar="TWINE_COMMAND", + default="pipx run twine upload", + help_text="The twine to run for Python release", + ), + OptionDef( + "npm-registry", + envvar="NPM_REGISTRY", + default="https://registry.npmjs.org/", + help_text="The npm registry to target for publishing", + ), + OptionDef( + "twine-repository-url", + envvar="TWINE_REPOSITORY_URL", + default="https://upload.pypi.org/legacy/", + help_text="The pypi registry to target for publishing", + ), + OptionDef( + "npm-tag", + envvar="NPM_TAG", + default="", + help_text="The npm tag. It defaults to 'next' if it is a prerelease otherwise to 'latest'.", + ), + OptionDef( + "expected-sha", + envvar="RH_EXPECTED_SHA", + help_text="The expected sha of the branch HEAD", + ), + ] - return _add_options + for opts in option_lists: + for opt in opts: + all_opts[opt.name] = opt + for opt in command_specific_options: + all_opts[opt.name] = opt -def use_checkout_dir(): - """Use the checkout dir created by prep-git""" + return all_opts - def inner(func): - ReleaseHelperGroup._needs_checkout_dir[func.__name__] = True - return func - return inner +# Command implementations -@main.command() -def list_envvars(): +def cmd_list_envvars(args): # noqa: ARG001 """List the environment variables""" - # This is implemented in ReleaseHelperGroup.invoke + all_opts = collect_all_options() + envvars: t.Dict[str, str] = {} + for name, opt in all_opts.items(): + if opt.envvar: + envvars[name] = opt.envvar + for key in sorted(envvars): + util.log(f"{key}: {envvars[key]}") -@main.command() -@add_options(branch_options) -@add_options(auth_options) -@add_options(username_options) -@add_options(git_url_options) -def prep_git(ref, branch, repo, auth, username, git_url): + +def cmd_prep_git(args, config_options): """Prep git and env variables and bump version""" - lib.prep_git(ref, branch, repo, auth, username, git_url) + opts = BRANCH_OPTIONS + AUTH_OPTIONS + USERNAME_OPTIONS + GIT_URL_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.prep_git( + values["ref"], + values["branch"], + values["repo"], + values["auth"], + values["username"], + values["git_url"], + ) -@main.command() -@add_options(version_spec_options) -@add_options(version_cmd_options) -@add_options(changelog_path_options) -@add_options(python_packages_options) -@add_options(tag_format_options) -@use_checkout_dir() -def bump_version(version_spec, version_cmd, changelog_path, python_packages, tag_format): +@use_checkout_dir +def cmd_bump_version(args, config_options): """Prep git and env variables and bump version""" + opts = ( + VERSION_SPEC_OPTIONS + + VERSION_CMD_OPTIONS + + CHANGELOG_PATH_OPTIONS + + PYTHON_PACKAGES_OPTIONS + + TAG_FORMAT_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + prev_dir = os.getcwd() + python_packages = values["python_packages"] or ["."] for package in python_packages: package_path, package_name = ( package.split(":", maxsplit=1) if ":" in package else [package, None] ) os.chdir(package_path) lib.bump_version( - version_spec, - version_cmd, - changelog_path, - tag_format, + values["version_spec"], + values["version_cmd"], + values["changelog_path"], + values["tag_format"], package_name=package_name if len(python_packages) > 1 else None, ) os.chdir(prev_dir) -@main.command() -@add_options(dry_run_options) -@add_options(auth_options) -@add_options(changelog_path_options) -@add_options(release_url_options) -@add_options(silent_option) -@use_checkout_dir() -def extract_changelog(dry_run, auth, changelog_path, release_url, silent): +@use_checkout_dir +def cmd_extract_changelog(args, config_options): """Extract the changelog entry.""" - lib.extract_changelog(dry_run, auth, changelog_path, release_url, silent) + opts = ( + DRY_RUN_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + RELEASE_URL_OPTIONS + + SILENT_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.extract_changelog( + values["dry_run"], + values["auth"], + values["changelog_path"], + values["release_url"], + values["silent"], + ) -@main.command() -@add_options(changelog_options) -@use_checkout_dir() -def build_changelog( - ref, branch, repo, auth, changelog_path, since, since_last_stable, resolve_backports -): +@use_checkout_dir +def cmd_build_changelog(args, config_options): """Build changelog entry""" - # We don't silence building the entry as it will be extracted to - # populate the release body + opts = CHANGELOG_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } changelog.build_entry( - ref, branch, repo, auth, changelog_path, since, since_last_stable, resolve_backports + values["ref"], + values["branch"], + values["repo"], + values["auth"], + values["changelog_path"], + values["since"], + values["since_last_stable"], + values["resolve_backports"], ) -@main.command() -@add_options(version_spec_options) -@add_options(branch_options) -@add_options(since_options) -@add_options(auth_options) -@add_options(changelog_path_options) -@add_options(dry_run_options) -@add_options(post_version_spec_options) -@add_options(silent_option) -@add_options(tag_format_options) -@use_checkout_dir() -def draft_changelog( - version_spec, - ref, - branch, - repo, - since, - since_last_stable, - auth, - changelog_path, - dry_run, - post_version_spec, - post_version_message, - silent, - tag_format, -): +@use_checkout_dir +def cmd_draft_changelog(args, config_options): """Create a changelog entry PR""" + opts = ( + VERSION_SPEC_OPTIONS + + BRANCH_OPTIONS + + SINCE_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DRY_RUN_OPTIONS + + POST_VERSION_SPEC_OPTIONS + + SILENT_OPTIONS + + TAG_FORMAT_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } lib.draft_changelog( - version_spec, - ref, - branch, - repo, - since, - since_last_stable, - auth, - changelog_path, - dry_run, - post_version_spec, - post_version_message, - silent, - tag_format, + values["version_spec"], + values["ref"], + values["branch"], + values["repo"], + values["since"], + values["since_last_stable"], + values["auth"], + values["changelog_path"], + values["dry_run"], + values["post_version_spec"], + values["post_version_message"], + values["silent"], + values["tag_format"], ) -@main.command() -@add_options(dist_dir_options) -@add_options(python_packages_options) -@use_checkout_dir() -def build_python(dist_dir, python_packages): +@use_checkout_dir +def cmd_build_python(args, config_options): """Build Python dist files""" + opts = DIST_DIR_OPTIONS + PYTHON_PACKAGES_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + prev_dir = os.getcwd() clean = True + python_packages = values["python_packages"] or ["."] for python_package in [p.split(":")[0] for p in python_packages]: os.chdir(python_package) if not util.PYPROJECT.exists() and not util.SETUP_PY.exists(): @@ -477,20 +532,22 @@ def build_python(dist_dir, python_packages): f"Skipping build-python in {python_package} since there are no python package files" ) else: - python.build_dist(Path(os.path.relpath(".", python_package)) / dist_dir, clean=clean) + python.build_dist( + Path(os.path.relpath(".", python_package)) / values["dist_dir"], clean=clean + ) clean = False os.chdir(prev_dir) -@main.command() -@add_options(dist_dir_options) -@add_options(check_imports_options) -@add_options(pydist_check_options) -@use_checkout_dir() -def check_python( - dist_dir, check_imports, pydist_check_cmd, pydist_extra_check_cmds, pydist_resource_paths -): +@use_checkout_dir +def cmd_check_python(args, config_options): """Check Python dist files""" + opts = DIST_DIR_OPTIONS + CHECK_IMPORTS_OPTIONS + PYDIST_CHECK_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + + dist_dir = values["dist_dir"] for dist_file in glob(f"{dist_dir}/*"): if Path(dist_file).suffix not in [".gz", ".whl"]: util.log(f"Skipping non-python dist file {dist_file}") @@ -498,253 +555,602 @@ def check_python( python.check_dist( dist_file, - python_imports=check_imports, - check_cmd=pydist_check_cmd, - extra_check_cmds=pydist_extra_check_cmds, - resource_paths=pydist_resource_paths, + python_imports=values["check_imports"] or [], + check_cmd=values["pydist_check_cmd"], + extra_check_cmds=values["pydist_extra_check_cmds"] or [], + resource_paths=values["pydist_resource_paths"] or [], ) -@main.command() -@add_options(dist_dir_options) -@click.argument("package", default=".") -@use_checkout_dir() -def build_npm(package, dist_dir): +@use_checkout_dir +def cmd_build_npm(args, config_options): """Build npm package""" + opts = DIST_DIR_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + + # Handle the positional argument + package = getattr(args, "package", None) or "." + if not osp.exists("./package.json"): util.log("Skipping build-npm since there is no package.json file") return - npm.build_dist(package, dist_dir) + npm.build_dist(package, values["dist_dir"]) -@main.command() -@add_options(dist_dir_options) -@add_options(npm_install_options) -@add_options(repo_options) -@use_checkout_dir() -def check_npm(dist_dir, npm_install_options, repo): +@use_checkout_dir +def cmd_check_npm(args, config_options): """Check npm package""" + opts = DIST_DIR_OPTIONS + NPM_INSTALL_OPTIONS + REPO_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + if not osp.exists("./package.json"): util.log("Skipping check-npm since there is no package.json file") return - npm.check_dist(dist_dir, npm_install_options, repo) + npm.check_dist(values["dist_dir"], values["npm_install_options"], values["repo"]) -@main.command() -@add_options(dist_dir_options) -@click.option( - "--release-message", - envvar="RH_RELEASE_MESSAGE", - default="Publish {version}", - help="The message to use for the release commit", -) -@add_options(tag_format_options) -@click.option( - "--tag-message", - envvar="RH_TAG_MESSAGE", - default="Release {tag_name}", - help="The message to use for the release tag", -) -@click.option( - "--no-git-tag-workspace", - is_flag=True, - help="Whether to skip tagging npm workspace packages", -) -@use_checkout_dir() -def tag_release(dist_dir, release_message, tag_format, tag_message, no_git_tag_workspace): +@use_checkout_dir +def cmd_tag_release(args, config_options): """Create release commit and tag""" - lib.tag_release(dist_dir, release_message, tag_format, tag_message, no_git_tag_workspace) - - -@main.command() -@add_options(branch_options) -@add_options(version_cmd_options) -@add_options(auth_options) -@add_options(changelog_path_options) -@add_options(dist_dir_options) -@add_options(dry_run_options) -@add_options(release_url_options) -@add_options(post_version_spec_options) -@add_options(silent_option) -@add_options(tag_format_options) -@click.argument("assets", nargs=-1) -@use_checkout_dir() -def populate_release( - ref, - branch, - repo, - version_cmd, - auth, - changelog_path, - dist_dir, - dry_run, - release_url, - post_version_spec, - post_version_message, - silent, - tag_format, - assets, -): + tag_release_opts = [ + OptionDef( + "release-message", + envvar="RH_RELEASE_MESSAGE", + default="Publish {version}", + help_text="The message to use for the release commit", + ), + OptionDef( + "tag-message", + envvar="RH_TAG_MESSAGE", + default="Release {tag_name}", + help_text="The message to use for the release tag", + ), + OptionDef( + "no-git-tag-workspace", + is_flag=True, + help_text="Whether to skip tagging npm workspace packages", + ), + ] + opts = DIST_DIR_OPTIONS + TAG_FORMAT_OPTIONS + tag_release_opts + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.tag_release( + values["dist_dir"], + values["release_message"], + values["tag_format"], + values["tag_message"], + values["no_git_tag_workspace"], + ) + + +@use_checkout_dir +def cmd_populate_release(args, config_options): """Populate a release.""" + opts = ( + BRANCH_OPTIONS + + VERSION_CMD_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DIST_DIR_OPTIONS + + DRY_RUN_OPTIONS + + RELEASE_URL_OPTIONS + + POST_VERSION_SPEC_OPTIONS + + SILENT_OPTIONS + + TAG_FORMAT_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + + # Handle assets positional argument + assets = getattr(args, "assets", None) or [] + lib.populate_release( - ref, - branch, - repo, - version_cmd, - auth, - changelog_path, - dist_dir, - dry_run, - release_url, - post_version_spec, - post_version_message, + values["ref"], + values["branch"], + values["repo"], + values["version_cmd"], + values["auth"], + values["changelog_path"], + values["dist_dir"], + values["dry_run"], + values["release_url"], + values["post_version_spec"], + values["post_version_message"], assets, - tag_format, - silent, + values["tag_format"], + values["silent"], ) -@main.command() -@add_options(auth_options) -@add_options(dry_run_options) -@add_options(release_url_options) -@use_checkout_dir() -def delete_release(auth, dry_run, release_url): +@use_checkout_dir +def cmd_delete_release(args, config_options): """Delete a draft GitHub release by url to the release page""" - lib.delete_release(auth, release_url, dry_run) + opts = AUTH_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.delete_release(values["auth"], values["release_url"], values["dry_run"]) -@main.command() -@add_options(auth_options) -@add_options(dist_dir_options) -@add_options(dry_run_options) -@add_options(release_url_options) -def extract_release(auth, dist_dir, dry_run, release_url): +def cmd_extract_release(args, config_options): """Download and verify assets from a draft GitHub release""" + opts = AUTH_OPTIONS + DIST_DIR_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } lib.extract_release( - auth, - dist_dir, - dry_run, - release_url, + values["auth"], + values["dist_dir"], + values["dry_run"], + values["release_url"], ) -@main.command() -@add_options(auth_options) -@add_options(dist_dir_options) -@click.option("--npm-token", help="A token for the npm release", envvar="NPM_TOKEN") -@click.option( - "--npm-cmd", - help="The command to run for npm release", - envvar="RH_NPM_COMMAND", - default="npm publish", -) -@click.option( - "--twine-cmd", - help="The twine to run for Python release", - envvar="TWINE_COMMAND", - default="pipx run twine upload", -) -@click.option( - "--npm-registry", - help="The npm registry to target for publishing", - envvar="NPM_REGISTRY", - default="https://registry.npmjs.org/", -) -@click.option( - "--twine-repository-url", - help="The pypi registry to target for publishing", - envvar="TWINE_REPOSITORY_URL", - default="https://upload.pypi.org/legacy/", -) -@click.option( - "--npm-tag", - help="The npm tag. It defaults to 'next' if it is a prerelease otherwise to 'latest'.", - envvar="NPM_TAG", - default="", -) -@add_options(dry_run_options) -@add_options(python_packages_options) -@add_options(release_url_options) -@use_checkout_dir() -def publish_assets( - auth, - dist_dir, - npm_token, - npm_cmd, - twine_cmd, - npm_registry, - twine_repository_url, - npm_tag, - dry_run, - release_url, - python_packages, -): +@use_checkout_dir +def cmd_publish_assets(args, config_options): """Publish release asset(s)""" + publish_opts = [ + OptionDef("npm-token", envvar="NPM_TOKEN", help_text="A token for the npm release"), + OptionDef( + "npm-cmd", + envvar="RH_NPM_COMMAND", + default="npm publish", + help_text="The command to run for npm release", + ), + OptionDef( + "twine-cmd", + envvar="TWINE_COMMAND", + default="pipx run twine upload", + help_text="The twine to run for Python release", + ), + OptionDef( + "npm-registry", + envvar="NPM_REGISTRY", + default="https://registry.npmjs.org/", + help_text="The npm registry to target for publishing", + ), + OptionDef( + "twine-repository-url", + envvar="TWINE_REPOSITORY_URL", + default="https://upload.pypi.org/legacy/", + help_text="The pypi registry to target for publishing", + ), + OptionDef( + "npm-tag", + envvar="NPM_TAG", + default="", + help_text="The npm tag. It defaults to 'next' if it is a prerelease otherwise to 'latest'.", + ), + ] + opts = ( + AUTH_OPTIONS + + DIST_DIR_OPTIONS + + publish_opts + + DRY_RUN_OPTIONS + + PYTHON_PACKAGES_OPTIONS + + RELEASE_URL_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + + python_packages = values["python_packages"] or ["."] for python_package in python_packages: lib.publish_assets( - auth, - dist_dir, - npm_token, - npm_cmd, - twine_cmd, - npm_registry, - twine_repository_url, - npm_tag, - dry_run, - release_url, + values["auth"], + values["dist_dir"], + values["npm_token"], + values["npm_cmd"], + values["twine_cmd"], + values["npm_registry"], + values["twine_repository_url"], + values["npm_tag"], + values["dry_run"], + values["release_url"], python_package, ) -@main.command() -@add_options(auth_options) -@add_options(dry_run_options) -@add_options(release_url_options) -@add_options(silent_option) -@use_checkout_dir() -def publish_release(auth, dry_run, release_url, silent): +@use_checkout_dir +def cmd_publish_release(args, config_options): """Publish GitHub release""" - lib.publish_release(auth, dry_run, release_url, silent) + opts = AUTH_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS + SILENT_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.publish_release(values["auth"], values["dry_run"], values["release_url"], values["silent"]) -@main.command() -@add_options(branch_options) -@add_options(dry_run_options) -@click.option( - "--expected-sha", help="The expected sha of the branch HEAD", envvar="RH_EXPECTED_SHA" -) -@use_checkout_dir() -def ensure_sha(ref, branch, repo, dry_run, expected_sha): # noqa: ARG001 +@use_checkout_dir +def cmd_ensure_sha(args, config_options): """Ensure that a sha has not changed.""" - util.ensure_sha(dry_run, expected_sha, branch) - - -@main.command() -@add_options(auth_options) -@add_options(branch_options) -@add_options(username_options) -@add_options(changelog_path_options) -@add_options(dry_run_options) -@add_options(release_url_options) -@use_checkout_dir() -def forwardport_changelog(auth, ref, branch, repo, username, changelog_path, dry_run, release_url): + ensure_sha_opts = [ + OptionDef( + "expected-sha", + envvar="RH_EXPECTED_SHA", + help_text="The expected sha of the branch HEAD", + ), + ] + opts = BRANCH_OPTIONS + DRY_RUN_OPTIONS + ensure_sha_opts + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + util.ensure_sha(values["dry_run"], values["expected_sha"], values["branch"]) + + +@use_checkout_dir +def cmd_forwardport_changelog(args, config_options): """Forwardport Changelog Entries to the Default Branch""" + opts = ( + AUTH_OPTIONS + + BRANCH_OPTIONS + + USERNAME_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DRY_RUN_OPTIONS + + RELEASE_URL_OPTIONS + ) + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } lib.forwardport_changelog( - auth, ref, branch, repo, username, changelog_path, dry_run, release_url + values["auth"], + values["ref"], + values["branch"], + values["repo"], + values["username"], + values["changelog_path"], + values["dry_run"], + values["release_url"], ) -@main.command() -@add_options(auth_options) -@add_options(branch_options) -@add_options(changelog_path_options) -@add_options(dry_run_options) -@use_checkout_dir() -def publish_changelog(auth, ref, branch, repo, changelog_path, dry_run): # noqa: ARG001 +@use_checkout_dir +def cmd_publish_changelog(args, config_options): """Remove changelog placeholder entries.""" - lib.publish_changelog(branch, repo, auth, changelog_path, dry_run) + opts = AUTH_OPTIONS + BRANCH_OPTIONS + CHANGELOG_PATH_OPTIONS + DRY_RUN_OPTIONS + values = { + opt.name.replace("-", "_"): get_option_value(args, opt, config_options) for opt in opts + } + lib.publish_changelog( + values["branch"], + values["repo"], + values["auth"], + values["changelog_path"], + values["dry_run"], + ) + + +# Command registry - maps command names to their handlers and options +COMMANDS: t.Dict[str, t.Dict[str, t.Any]] = { + "list-envvars": { + "func": cmd_list_envvars, + "help": "List the environment variables", + "options": [], + "simple": True, # Does not need config processing + }, + "prep-git": { + "func": cmd_prep_git, + "help": "Prep git and env variables and bump version", + "options": BRANCH_OPTIONS + AUTH_OPTIONS + USERNAME_OPTIONS + GIT_URL_OPTIONS, + }, + "bump-version": { + "func": cmd_bump_version, + "help": "Prep git and env variables and bump version", + "options": VERSION_SPEC_OPTIONS + + VERSION_CMD_OPTIONS + + CHANGELOG_PATH_OPTIONS + + PYTHON_PACKAGES_OPTIONS + + TAG_FORMAT_OPTIONS, + }, + "extract-changelog": { + "func": cmd_extract_changelog, + "help": "Extract the changelog entry", + "options": DRY_RUN_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + RELEASE_URL_OPTIONS + + SILENT_OPTIONS, + }, + "build-changelog": { + "func": cmd_build_changelog, + "help": "Build changelog entry", + "options": CHANGELOG_OPTIONS, + }, + "draft-changelog": { + "func": cmd_draft_changelog, + "help": "Create a changelog entry PR", + "options": ( + VERSION_SPEC_OPTIONS + + BRANCH_OPTIONS + + SINCE_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DRY_RUN_OPTIONS + + POST_VERSION_SPEC_OPTIONS + + SILENT_OPTIONS + + TAG_FORMAT_OPTIONS + ), + }, + "build-python": { + "func": cmd_build_python, + "help": "Build Python dist files", + "options": DIST_DIR_OPTIONS + PYTHON_PACKAGES_OPTIONS, + }, + "check-python": { + "func": cmd_check_python, + "help": "Check Python dist files", + "options": DIST_DIR_OPTIONS + CHECK_IMPORTS_OPTIONS + PYDIST_CHECK_OPTIONS, + }, + "build-npm": { + "func": cmd_build_npm, + "help": "Build npm package", + "options": DIST_DIR_OPTIONS, + "positional_args": [("package", {"nargs": "?", "default": "."})], + }, + "check-npm": { + "func": cmd_check_npm, + "help": "Check npm package", + "options": DIST_DIR_OPTIONS + NPM_INSTALL_OPTIONS + REPO_OPTIONS, + }, + "tag-release": { + "func": cmd_tag_release, + "help": "Create release commit and tag", + "options": DIST_DIR_OPTIONS + + TAG_FORMAT_OPTIONS + + [ + OptionDef( + "release-message", + envvar="RH_RELEASE_MESSAGE", + default="Publish {version}", + help_text="The message to use for the release commit", + ), + OptionDef( + "tag-message", + envvar="RH_TAG_MESSAGE", + default="Release {tag_name}", + help_text="The message to use for the release tag", + ), + OptionDef( + "no-git-tag-workspace", + is_flag=True, + help_text="Whether to skip tagging npm workspace packages", + ), + ], + }, + "populate-release": { + "func": cmd_populate_release, + "help": "Populate a release", + "options": ( + BRANCH_OPTIONS + + VERSION_CMD_OPTIONS + + AUTH_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DIST_DIR_OPTIONS + + DRY_RUN_OPTIONS + + RELEASE_URL_OPTIONS + + POST_VERSION_SPEC_OPTIONS + + SILENT_OPTIONS + + TAG_FORMAT_OPTIONS + ), + "positional_args": [("assets", {"nargs": "*"})], + }, + "delete-release": { + "func": cmd_delete_release, + "help": "Delete a draft GitHub release by url to the release page", + "options": AUTH_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS, + }, + "extract-release": { + "func": cmd_extract_release, + "help": "Download and verify assets from a draft GitHub release", + "options": AUTH_OPTIONS + DIST_DIR_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS, + }, + "publish-assets": { + "func": cmd_publish_assets, + "help": "Publish release asset(s)", + "options": AUTH_OPTIONS + + DIST_DIR_OPTIONS + + [ + OptionDef("npm-token", envvar="NPM_TOKEN", help_text="A token for the npm release"), + OptionDef( + "npm-cmd", + envvar="RH_NPM_COMMAND", + default="npm publish", + help_text="The command to run for npm release", + ), + OptionDef( + "twine-cmd", + envvar="TWINE_COMMAND", + default="pipx run twine upload", + help_text="The twine to run for Python release", + ), + OptionDef( + "npm-registry", + envvar="NPM_REGISTRY", + default="https://registry.npmjs.org/", + help_text="The npm registry to target for publishing", + ), + OptionDef( + "twine-repository-url", + envvar="TWINE_REPOSITORY_URL", + default="https://upload.pypi.org/legacy/", + help_text="The pypi registry to target for publishing", + ), + OptionDef( + "npm-tag", + envvar="NPM_TAG", + default="", + help_text="The npm tag. It defaults to 'next' if it is a prerelease otherwise to 'latest'.", + ), + ] + + DRY_RUN_OPTIONS + + PYTHON_PACKAGES_OPTIONS + + RELEASE_URL_OPTIONS, + }, + "publish-release": { + "func": cmd_publish_release, + "help": "Publish GitHub release", + "options": AUTH_OPTIONS + DRY_RUN_OPTIONS + RELEASE_URL_OPTIONS + SILENT_OPTIONS, + }, + "ensure-sha": { + "func": cmd_ensure_sha, + "help": "Ensure that a sha has not changed", + "options": BRANCH_OPTIONS + + DRY_RUN_OPTIONS + + [ + OptionDef( + "expected-sha", + envvar="RH_EXPECTED_SHA", + help_text="The expected sha of the branch HEAD", + ), + ], + }, + "forwardport-changelog": { + "func": cmd_forwardport_changelog, + "help": "Forwardport Changelog Entries to the Default Branch", + "options": AUTH_OPTIONS + + BRANCH_OPTIONS + + USERNAME_OPTIONS + + CHANGELOG_PATH_OPTIONS + + DRY_RUN_OPTIONS + + RELEASE_URL_OPTIONS, + }, + "publish-changelog": { + "func": cmd_publish_changelog, + "help": "Remove changelog placeholder entries", + "options": AUTH_OPTIONS + BRANCH_OPTIONS + CHANGELOG_PATH_OPTIONS + DRY_RUN_OPTIONS, + }, +} + + +def create_parser() -> argparse.ArgumentParser: + """Create the main argument parser with all subcommands""" + parser = argparse.ArgumentParser( + prog="jupyter-releaser", + description="Jupyter Releaser scripts", + ) + parser.add_argument( + "--force", + action="store_true", + default=False, + help="Force a command to run even when skipped by config", + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + for cmd_name, cmd_info in COMMANDS.items(): + subparser = subparsers.add_parser(cmd_name, help=cmd_info["help"]) + add_options_to_parser(subparser, cmd_info["options"]) + + # Add positional arguments if any + for pos_arg in cmd_info.get("positional_args", []): + arg_name, arg_kwargs = pos_arg + subparser.add_argument(arg_name, **arg_kwargs) + + return parser + + +def run_command(args: argparse.Namespace) -> int: + """Run the specified command with proper handling""" + cmd_name = args.command + + if cmd_name is None: + # No command provided, show help + create_parser().print_help() + return 0 + + if cmd_name not in COMMANDS: + util.log(f"Unknown command: {cmd_name}") + return 1 + + cmd_info = COMMANDS[cmd_name] + + # Handle simple commands that don't need config processing + if cmd_info.get("simple"): + cmd_info["func"](args) + return 0 + + orig_dir = os.getcwd() + + try: + # Check if we need to be in checkout directory + if cmd_name in _needs_checkout_dir: + if not osp.exists(util.CHECKOUT_NAME): + msg = "Please run prep-git first" + raise ValueError(msg) + os.chdir(util.CHECKOUT_NAME) + + # Read in the config + config = util.read_config() + hooks = config.get("hooks", {}) + config_options = config.get("options", {}) + skip = config.get("skip", []) + + if args.force: + skip = [] + + skip += os.environ.get("RH_STEPS_TO_SKIP", "").split(",") + + # Print a separation header + util.log(f'\n\n{"-" * 50}') + util.log(f"\n\n{cmd_name}\n\n") + util.log(f'\n\n{"-" * 50}') + + if cmd_name in skip or cmd_name.replace("-", "_") in skip: + util.log("*** Skipping based on skip config") + util.log(f'{"-" * 50}\n\n') + return 0 + + util.log(f'{"~" * 50}\n\n') + + # Handle before hooks + before = f"before-{cmd_name}" + if before in hooks: + before_hooks = hooks[before] + if isinstance(before_hooks, str): + before_hooks = [before_hooks] + if before_hooks: + util.log(f"\nRunning hooks for {before}") + for hook in before_hooks: + util.run(hook) + + # Run the actual command + cmd_info["func"](args, config_options) + + # Handle after hooks + # Re-read config if we just did a git checkout + if cmd_name in ["prep-git", "extract-release"]: + os.chdir(util.CHECKOUT_NAME) + config = util.read_config() + hooks = config.get("hooks", {}) + + after = f"after-{cmd_name}" + if after in hooks: + after_hooks = hooks[after] + if isinstance(after_hooks, str): + after_hooks = [after_hooks] + if after_hooks: + util.log(f"\nRunning hooks for {after}") + for hook in after_hooks: + util.run(hook) + + return 0 + + finally: + os.chdir(orig_dir) + + +def main(args: t.Optional[t.List[str]] = None) -> int: + """Main entry point for the CLI""" + parser = create_parser() + parsed_args = parser.parse_args(args) + return run_command(parsed_args) if __name__ == "__main__": # pragma: no cover - main() + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index f20dcf71..8a05bb1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ urls = {Homepage = "https://jupyter.org"} requires-python = ">=3.9" dynamic = ["version"] dependencies = [ - "click<8.2.0", "ghapi<=1.0.4", "github-activity~=0.2", "importlib_resources", @@ -49,12 +48,12 @@ docs = [ "sphinx", "sphinx-autobuild", "sphinx-copybutton", + "sphinx-argparse", "pip", "myst-parser", "pydata_sphinx_theme", "sphinxcontrib_spelling", "numpydoc", - "sphinx-click", ] test = [ "fastapi", diff --git a/tests/conftest.py b/tests/conftest.py index fe482305..d858bc47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,16 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import io import json import os import os.path as osp +import sys import tempfile import time import uuid from pathlib import Path import pytest -from click.testing import CliRunner from ghapi.core import GhApi from jupyter_releaser import cli, util @@ -17,6 +18,18 @@ from tests import util as testutil +class CLIResult: + """Result from CLI invocation.""" + + def __init__(self, exit_code, output, exception=None): + self.exit_code = exit_code + self.output = output + self.stdout = output + self.stderr = "" + self.stderr_bytes = b"" + self.exception = exception + + @pytest.fixture(autouse=True) def github_port(worker_id): # The worker id will be of the form "gw123" unless xdist is disabled, @@ -193,16 +206,55 @@ def npm_dist_prerelease(workspace_package, runner, mocker, git_prep): @pytest.fixture() def runner(): - cli_runner = CliRunner() - - def run(*args, **kwargs): - result = cli_runner.invoke(cli.main, *args, **kwargs) - if result.exit_code != 0: + def run(args, env=None): + # Convert any Path objects to strings + args = [str(arg) if isinstance(arg, Path) else arg for arg in args] + + # Set up environment + if env: + old_env = os.environ.copy() + os.environ.update(env) + + # Capture stdout and stderr + old_stdout = sys.stdout + old_stderr = sys.stderr + sys.stdout = captured_stdout = io.StringIO() + sys.stderr = captured_stderr = io.StringIO() + + exception = None + exit_code = 0 + + try: + exit_code = cli.main(args) + if exit_code is None: + exit_code = 0 + except SystemExit as e: + exit_code = e.code if e.code is not None else 0 + except Exception as e: + exception = e + exit_code = 1 + + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + if env: + os.environ.clear() + os.environ.update(old_env) + + stdout_output = captured_stdout.getvalue() + stderr_output = captured_stderr.getvalue() + combined_output = stdout_output + stderr_output + result = CLIResult(exit_code, combined_output, exception) + result.stdout = stdout_output + result.stderr = stderr_output + result.stderr_bytes = stderr_output.encode() + + if exit_code != 0: if result.stderr_bytes: print("Captured stderr\n", result.stderr, "\n\n") print("Captured stdout\n", result.stdout, "\n\n") - assert result.exception is not None - raise result.exception + if exception is not None: + raise exception return result diff --git a/tests/util.py b/tests/util.py index 3bd231ed..13e8a614 100644 --- a/tests/util.py +++ b/tests/util.py @@ -18,7 +18,7 @@ [hooks] """ -for name in cli.main.commands: +for name in cli.COMMANDS: TOML_CONFIG += f"'before-{name}' = \"echo before-{name} >> 'log.txt'\"\n" TOML_CONFIG += f"'after-{name}' = \"echo after-{name} >> 'log.txt'\"\n"