diff --git a/CHANGES b/CHANGES index 668366b4..a47dc931 100644 --- a/CHANGES +++ b/CHANGES @@ -19,6 +19,18 @@ $ pipx install --suffix=@next 'vcspull' --pip-args '\--pre' --force - _Add your latest changes from PRs here_ +**Maintenance release, no features or fixes** + +### Internal + +- Move from click to :mod:{argparse} + + Click was more difficult to control and workwith, ironically. + +### Packaging + +- Drop click dependency (#400) + ## vcspull v1.14.0 (2022-10-01) **Maintenance release, no features or fixes** @@ -230,7 +242,7 @@ Patch branch: [`v1.12.x`](https://github.com/vcs-python/vcspull/tree/v1.12.x) ### Fix -- Tab-completion for repository names and configurations +- Tab-completion for repository names and configurations (retracted in v1.15) ## vcspull 1.11.1 (2022-03-12) @@ -272,7 +284,7 @@ Patch branch: [`v1.12.x`](https://github.com/vcs-python/vcspull/tree/v1.12.x) ### Improvements -- Experimental completion, see {ref}`completion`: +- Experimental completion (retracted in v1.15): - Completion for sync: @@ -281,7 +293,7 @@ Patch branch: [`v1.12.x`](https://github.com/vcs-python/vcspull/tree/v1.12.x) ### Documentation -- Added {ref}`completion`: +- Added completion: ## vcspull 1.9.0 (2022-02-26) diff --git a/docs/cli/completion.md b/docs/cli/completion.md deleted file mode 100644 index 48a78168..00000000 --- a/docs/cli/completion.md +++ /dev/null @@ -1,27 +0,0 @@ -(completion)= - -# Completion - -```{note} -See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/). -``` - -:::{tab} bash - -_~/.bashrc_: - -```bash -eval "$(_VCSPULL_COMPLETE=bash_source vcspull)" -``` - -::: - -:::{tab} zsh - -_~/.zshrc`_: - -```zsh -eval "$(_VCSPULL_COMPLETE=zsh_source vscpull)" -``` - -::: diff --git a/docs/cli/index.md b/docs/cli/index.md index b75f43fe..484d474e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -14,10 +14,3 @@ vcspull sync ``` - -```{toctree} -:caption: More -:maxdepth: 1 - -completion -``` diff --git a/docs/cli/sync.md b/docs/cli/sync.md index 81716b91..a0b567a6 100644 --- a/docs/cli/sync.md +++ b/docs/cli/sync.md @@ -4,6 +4,16 @@ # vcspull sync +## Command + +```{eval-rst} +.. argparse:: + :module: vcspull.cli + :func: create_parser + :prog: vcspull + :path: sync +``` + ## Filtering repos As of 1.13.x, `$ vcspull sync` with no args passed will show a help dialog: @@ -80,10 +90,3 @@ Print traceback for errored repos: ```console $ vcspull --log-level DEBUG sync --exit-on-error grako django ``` - -```{eval-rst} -.. click:: vcspull.cli.sync:sync - :prog: vcspull sync - :commands: sync - :nested: full -``` diff --git a/docs/cli/vcspull.md b/docs/cli/vcspull.md index 9c2927ee..d220c548 100644 --- a/docs/cli/vcspull.md +++ b/docs/cli/vcspull.md @@ -5,7 +5,12 @@ # vcspull ```{eval-rst} -.. click:: vcspull.cli:cli - :prog: Usage - :nested: none +.. argparse:: + :module: vcspull.cli + :func: create_parser + :prog: vcspull + :nosubcommands: + + subparser_name : @replace + See :ref:`cli-sync` ``` diff --git a/docs/conf.py b/docs/conf.py index b75415ec..02c0c4a6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ "sphinx.ext.todo", "sphinx.ext.napoleon", "sphinx.ext.linkcode", - "sphinx_click.ext", # sphinx-click + "sphinxarg.ext", # sphinx-argparse "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", diff --git a/docs/developing.md b/docs/developing.md index d7fa7f71..c013f24f 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -256,11 +256,12 @@ requires [`entr(1)`]. ````{tab} Configuration -See `[flake8]` in setup.cfg. +See `[tool.mypy]` in pyproject.toml. -```{literalinclude} ../setup.cfg -:language: ini -:start-at: "[mypy]" +```{literalinclude} ../pyproject.toml +:language: toml +:start-at: "[tool.mypy]" +:end-before: "[tool" ``` diff --git a/poetry.lock b/poetry.lock index 641bc396..aaa8810b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -91,7 +91,7 @@ unicode_backport = ["unicodedata2"] name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -134,11 +134,11 @@ toml = ["tomli"] [[package]] name = "docutils" -version = "0.18.1" +version = "0.19" description = "Docutils -- Python Documentation Utilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.7" [[package]] name = "flake8" @@ -195,14 +195,14 @@ sphinx-basic-ng = "*" [[package]] name = "gp-libs" -version = "0.0.1a12" +version = "0.0.1a16" description = "Internal utilities for projects following git-pull python package spec" category = "dev" optional = false python-versions = ">=3.7,<4.0" [package.dependencies] -docutils = ">=0.18.0,<0.19.0" +docutils = "*" myst_parser = "*" [[package]] @@ -223,7 +223,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.13.0" +version = "5.0.0" description = "Read metadata from Python packages" category = "dev" optional = false @@ -555,7 +555,7 @@ watchdog = ">=2.0.0" [[package]] name = "pytz" -version = "2022.2.1" +version = "2022.4" description = "World timezone definitions, modern and historical" category = "dev" optional = false @@ -656,6 +656,20 @@ docs = ["sphinxcontrib-websupport"] lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "types-requests", "types-typed-ast"] test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] +[[package]] +name = "sphinx-argparse" +version = "0.3.1" +description = "A sphinx extension that automatically documents argparse commands and options" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +sphinx = ">=1.2.0" + +[package.extras] +markdown = ["CommonMark (>=0.5.6)"] + [[package]] name = "sphinx-autobuild" version = "2021.3.14" @@ -702,19 +716,6 @@ sphinx = ">=4.0" [package.extras] docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] -[[package]] -name = "sphinx-click" -version = "4.3.0" -description = "Sphinx extension that automatically documents click applications" -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -click = ">=7.0" -docutils = "*" -sphinx = ">=2.0" - [[package]] name = "sphinx-copybutton" version = "0.5.0" @@ -943,7 +944,7 @@ test = [] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "c551058abc184649683e8256fffc436d33ef57d679c341ea2de0bd93202c07c3" +content-hash = "a6fc173cf05d34ef279cacb759c738f714adc4f014c08d19c4195d94f61fc48a" [metadata.files] alabaster = [ @@ -1060,8 +1061,8 @@ coverage = [ {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, ] docutils = [ - {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, - {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] flake8 = [ {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, @@ -1080,8 +1081,8 @@ furo = [ {file = "furo-2022.9.29.tar.gz", hash = "sha256:d4238145629c623609c2deb5384f8d036e2a1ee2a101d64b67b4348112470dbd"}, ] gp-libs = [ - {file = "gp-libs-0.0.1a12.tar.gz", hash = "sha256:3a9a3018fa524a0008dd2a88197b2ab503a769bfa780337bf00f5753e1b95552"}, - {file = "gp_libs-0.0.1a12-py3-none-any.whl", hash = "sha256:7115eb6f65de812352fd08da1316a31458d3ceedede3fb9f7f4d2236aae0ca27"}, + {file = "gp-libs-0.0.1a16.tar.gz", hash = "sha256:37e4bb88e09b451bb5bb39dd450b0aa878eb70a8859b7426ea327db7306464b1"}, + {file = "gp_libs-0.0.1a16-py3-none-any.whl", hash = "sha256:07f532df6921b6ac5f512636f7927ffff9ba55011b2e4b301ae8d854cb0c8f91"}, ] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, @@ -1092,8 +1093,8 @@ imagesize = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, - {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1259,8 +1260,8 @@ pytest-watcher = [ {file = "pytest_watcher-0.2.3-py3-none-any.whl", hash = "sha256:af935963399509a5b0e855740ba7227852f1a7fccfbb1cbb79fa19a445af02d2"}, ] pytz = [ - {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"}, - {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"}, + {file = "pytz-2022.4-py2.py3-none-any.whl", hash = "sha256:2c0784747071402c6e99f0bafdb7da0fa22645f06554c7ae06bf6358897e9c91"}, + {file = "pytz-2022.4.tar.gz", hash = "sha256:48ce799d83b6f8aab2020e369b627446696619e79645419610b9facd909b3174"}, ] PyYAML = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, @@ -1321,6 +1322,10 @@ Sphinx = [ {file = "Sphinx-5.2.3.tar.gz", hash = "sha256:5b10cb1022dac8c035f75767799c39217a05fc0fe2d6fe5597560d38e44f0363"}, {file = "sphinx-5.2.3-py3-none-any.whl", hash = "sha256:7abf6fabd7b58d0727b7317d5e2650ef68765bbe0ccb63c8795fa8683477eaa2"}, ] +sphinx-argparse = [ + {file = "sphinx-argparse-0.3.1.tar.gz", hash = "sha256:82151cbd43ccec94a1530155f4ad34f251aaca6a0ffd5516d7fadf952d32dc1e"}, + {file = "sphinx_argparse-0.3.1-py2.py3-none-any.whl", hash = "sha256:295ccae425874630b6a3b47254854027345d786bab2c3ffd5e9a0407bc6856b2"}, +] sphinx-autobuild = [ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"}, {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"}, @@ -1333,10 +1338,6 @@ sphinx-basic-ng = [ {file = "sphinx_basic_ng-1.0.0b1-py3-none-any.whl", hash = "sha256:ade597a3029c7865b24ad0eda88318766bcc2f9f4cef60df7e28126fde94db2a"}, {file = "sphinx_basic_ng-1.0.0b1.tar.gz", hash = "sha256:89374bd3ccd9452a301786781e28c8718e99960f2d4f411845ea75fc7bb5a9b0"}, ] -sphinx-click = [ - {file = "sphinx-click-4.3.0.tar.gz", hash = "sha256:bd4db5d3c1bec345f07af07b8e28a76cfc5006d997984e38ae246bbf8b9a3b38"}, - {file = "sphinx_click-4.3.0-py3-none-any.whl", hash = "sha256:23e85a3cb0b728a421ea773699f6acadefae171d1a764a51dd8ec5981503ccbe"}, -] sphinx-copybutton = [ {file = "sphinx-copybutton-0.5.0.tar.gz", hash = "sha256:a0c059daadd03c27ba750da534a92a63e7a36a7736dcf684f26ee346199787f6"}, {file = "sphinx_copybutton-0.5.0-py3-none-any.whl", hash = "sha256:9684dec7434bd73f0eea58dda93f9bb879d24bff2d8b187b1f2ec08dfe7b5f48"}, diff --git a/pyproject.toml b/pyproject.toml index 96153674..d13b5908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "vcspull" -version = "1.14.0" +version = "1.15.0a0" description = "Manage and sync multiple git, mercurial, and svn repos" license = "MIT" authors = ["Tony Narlock "] @@ -59,7 +59,6 @@ vcspull = 'vcspull:cli.cli' [tool.poetry.dependencies] python = "^3.9" -click = "~8" libvcs = "~0.17.0" colorama = ">=0.3.9" @@ -67,10 +66,9 @@ colorama = ">=0.3.9" ### Docs ### sphinx = "*" furo = "*" -gp-libs = "0.0.1a12" +gp-libs = "0.0.1a16" sphinx-autobuild = "*" sphinx-autodoc-typehints = "*" -sphinx-click = "*" sphinx-inline-tabs = "*" sphinxext-opengraph = "*" sphinx-copybutton = "*" @@ -105,7 +103,6 @@ types-colorama = "*" [tool.poetry.extras] docs = [ "sphinx", - "sphinx-click", "sphinx-autodoc-typehints", "sphinx-autobuild", "sphinxext-rediraffe", @@ -129,6 +126,9 @@ lint = [ "types-colorama", ] +[tool.poetry.group.dev.dependencies] +sphinx-argparse = "^0.3.1" + [tool.mypy] python_version = 3.9 warn_unused_configs = true diff --git a/setup.cfg b/setup.cfg index f2b0fa7f..43680461 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,8 @@ line_length = 88 [tool:pytest] addopts = --tb=short --no-header --showlocals +filterwarnings = + ignore:The frontend.Option(Parser)? class.*:DeprecationWarning:: testpaths = src/vcspull tests diff --git a/src/vcspull/__about__.py b/src/vcspull/__about__.py index ab09b9e4..75e6db32 100644 --- a/src/vcspull/__about__.py +++ b/src/vcspull/__about__.py @@ -1,7 +1,7 @@ __title__ = "vcspull" __package_name__ = "vcspull" __description__ = "Manage and sync multiple git, mercurial, and svn repos" -__version__ = "1.14.0" +__version__ = "1.15.0a0" __author__ = "Tony Narlock" __github__ = "https://github.com/vcs-python/vcspull" __docs__ = "https://vcspull.git-pull.com" diff --git a/src/vcspull/cli/__init__.py b/src/vcspull/cli/__init__.py index 1d36ef0c..0ba013f3 100644 --- a/src/vcspull/cli/__init__.py +++ b/src/vcspull/cli/__init__.py @@ -4,39 +4,52 @@ ~~~~~~~~~~~ """ +import argparse import logging -import click - from libvcs.__about__ import __version__ as libvcs_version from ..__about__ import __version__ from ..log import setup_logger -from .sync import sync +from .sync import create_sync_subparser, sync log = logging.getLogger(__name__) -@click.group( - context_settings={ - "obj": {}, - "help_option_names": ["-h", "--help"], - } -) -@click.option( - "--log-level", - default="INFO", - help="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -@click.version_option( - __version__, - "-V", - "--version", - message=f"%(prog)s %(version)s, libvcs {libvcs_version}", -) -def cli(log_level): - setup_logger(log=log, level=log_level.upper()) - - -# Register sub-commands here -cli.add_command(sync) +def create_parser(): + parser = argparse.ArgumentParser(prog="vcspull") + parser.add_argument( + "--version", + "-V", + action="version", + version=f"%(prog)s {__version__}, libvcs {libvcs_version}", + ) + parser.add_argument( + "--log-level", + action="store", + default="INFO", + help="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", + ) + subparsers = parser.add_subparsers(dest="subparser_name") + sync_parser = subparsers.add_parser("sync") + create_sync_subparser(sync_parser) + + return parser + + +def cli(args=None): + parser = create_parser() + args = parser.parse_args(args) + + setup_logger(log=log, level=args.log_level.upper()) + + if args.subparser_name is None: + parser.print_help() + return + elif args.subparser_name == "sync": + sync( + repo_terms=args.repo_terms, + config=args.config, + exit_on_error=args.exit_on_error, + parser=parser, + ) diff --git a/src/vcspull/cli/sync.py b/src/vcspull/cli/sync.py index 47b9c467..57aef33f 100644 --- a/src/vcspull/cli/sync.py +++ b/src/vcspull/cli/sync.py @@ -1,60 +1,17 @@ +import argparse import logging import sys +import typing as t from copy import deepcopy -import click -import click.shell_completion -from click.shell_completion import CompletionItem - from libvcs._internal.shortcuts import create_project from libvcs.url import registry as url_tools -from vcspull.types import ConfigDict from ..config import filter_repos, find_config_files, load_configs log = logging.getLogger(__name__) -def get_repo_completions( - ctx: click.Context, param: click.Parameter, incomplete: str -) -> list[CompletionItem]: - configs = ( - load_configs(find_config_files(include_home=True)) - if ctx.params["config"] is None - else load_configs(files=[ctx.params["config"]]) - ) - found_repos: list[ConfigDict] = [] - repo_terms = [incomplete] - - for repo_term in repo_terms: - dir, vcs_url, name = None, None, None - if any(repo_term.startswith(n) for n in ["./", "/", "~", "$HOME"]): - dir = dir - elif any(repo_term.startswith(n) for n in ["http", "git", "svn", "hg"]): - vcs_url = repo_term - else: - name = repo_term - - # collect the repos from the config files - found_repos.extend(filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name)) - if len(found_repos) == 0: - found_repos = configs - - return [ - CompletionItem(o["name"]) - for o in found_repos - if o["name"].startswith(incomplete) - ] - - -def get_config_file_completions(ctx, args, incomplete): - return [ - click.shell_completion.CompletionItem(c) - for c in find_config_files(include_home=True) - if str(c).startswith(incomplete) - ] - - def clamp(n, _min, _max): return max(_min, min(n, _max)) @@ -63,60 +20,53 @@ def clamp(n, _min, _max): NO_REPOS_FOR_TERM_MSG = 'No repo found in config(s) for "{name}"' -@click.command(name="sync") -@click.pass_context -@click.argument( - "repo_terms", type=click.STRING, nargs=-1, shell_complete=get_repo_completions -) -@click.option( - "config", - "--config", - "-c", - type=click.Path(exists=True), - help="Specify config", - shell_complete=get_config_file_completions, -) -@click.option( - "exit_on_error", - "--exit-on-error", - "-x", - is_flag=True, - default=False, - help="Exit immediately when encountering an error syncing multiple repos", -) -def sync(ctx, repo_terms, config, exit_on_error: bool) -> None: +def create_sync_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + parser.add_argument("--config", "-c", help="Specify config") + parser.add_argument("repo_terms", nargs="+", help="Specify config") + parser.add_argument( + "--exit-on-error", + "-x", + action="store_true", + dest="exit_on_error", + help="Specify config", + ) + return parser + + +def sync( + repo_terms, + config, + exit_on_error: bool, + parser: t.Optional[ + argparse.ArgumentParser + ] = None, # optional so sync can be unit tested +) -> None: if config: configs = load_configs([config]) else: configs = load_configs(find_config_files(include_home=True)) found_repos = [] - if repo_terms: - for repo_term in repo_terms: - dir, vcs_url, name = None, None, None - if any(repo_term.startswith(n) for n in ["./", "/", "~", "$HOME"]): - dir = repo_term - elif any(repo_term.startswith(n) for n in ["http", "git", "svn", "hg"]): - vcs_url = repo_term - else: - name = repo_term - - # collect the repos from the config files - found = filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name) - if len(found) == 0: - click.echo(NO_REPOS_FOR_TERM_MSG.format(name=name)) - found_repos.extend( - filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name) - ) - else: - click.echo(ctx.get_help(), color=ctx.color) - ctx.exit() + for repo_term in repo_terms: + dir, vcs_url, name = None, None, None + if any(repo_term.startswith(n) for n in ["./", "/", "~", "$HOME"]): + dir = repo_term + elif any(repo_term.startswith(n) for n in ["http", "git", "svn", "hg"]): + vcs_url = repo_term + else: + name = repo_term + + # collect the repos from the config files + found = filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name) + if len(found) == 0: + print(NO_REPOS_FOR_TERM_MSG.format(name=name)) + found_repos.extend(filter_repos(configs, dir=dir, vcs_url=vcs_url, name=name)) for repo in found_repos: try: update_repo(repo) except Exception: - click.echo( + print( f'Failed syncing {repo.get("name")}', ) if log.isEnabledFor(logging.DEBUG): @@ -124,7 +74,10 @@ def sync(ctx, repo_terms, config, exit_on_error: bool) -> None: traceback.print_exc() if exit_on_error: - raise click.ClickException(EXIT_ON_ERROR_MSG) + if parser is not None: + parser.exit(status=1, message=EXIT_ON_ERROR_MSG) + else: + raise SystemExit(EXIT_ON_ERROR_MSG) def progress_cb(output, timestamp): diff --git a/tests/test_cli.py b/tests/test_cli.py index 4085f772..0fee8b09 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,6 @@ import pytest import yaml -from click.testing import CliRunner from libvcs.sync.git import GitSync from vcspull.__about__ import __version__ @@ -22,8 +21,10 @@ class SyncCLINonExistentRepo(t.NamedTuple): test_id: str sync_args: list[str] expected_exit_code: int - expected_in_output: "ExpectedOutput" = None - expected_not_in_output: "ExpectedOutput" = None + expected_in_out: "ExpectedOutput" = None + expected_not_in_out: "ExpectedOutput" = None + expected_in_err: "ExpectedOutput" = None + expected_not_in_err: "ExpectedOutput" = None SYNC_CLI_EXISTENT_REPO_FIXTURES = [ @@ -31,24 +32,24 @@ class SyncCLINonExistentRepo(t.NamedTuple): test_id="exists", sync_args=["my_git_project"], expected_exit_code=0, - expected_in_output="Already on 'master'", - expected_not_in_output=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), + expected_in_out="Already on 'master'", + expected_not_in_out=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), ), SyncCLINonExistentRepo( test_id="non-existent-only", sync_args=["this_isnt_in_the_config"], expected_exit_code=0, - expected_in_output=NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"), + expected_in_out=NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"), ), SyncCLINonExistentRepo( test_id="non-existent-mixed", sync_args=["this_isnt_in_the_config", "my_git_project", "another"], expected_exit_code=0, - expected_in_output=[ + expected_in_out=[ NO_REPOS_FOR_TERM_MSG.format(name="this_isnt_in_the_config"), NO_REPOS_FOR_TERM_MSG.format(name="another"), ], - expected_not_in_output=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), + expected_not_in_out=NO_REPOS_FOR_TERM_MSG.format(name="my_git_repo"), ), ] @@ -59,15 +60,19 @@ class SyncCLINonExistentRepo(t.NamedTuple): ids=[test.test_id for test in SYNC_CLI_EXISTENT_REPO_FIXTURES], ) def test_sync_cli_repo_term_non_existent( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, config_path: pathlib.Path, - tmp_path: pathlib.Path, git_repo: GitSync, test_id: str, sync_args: list[str], expected_exit_code: int, - expected_in_output: "ExpectedOutput", - expected_not_in_output: "ExpectedOutput", + expected_in_out: "ExpectedOutput", + expected_not_in_out: "ExpectedOutput", + expected_in_err: "ExpectedOutput", + expected_not_in_err: "ExpectedOutput", ) -> None: config = { "~/github_projects/": { @@ -81,31 +86,37 @@ def test_sync_cli_repo_term_non_existent( yaml_config_data = yaml.dump(config, default_flow_style=False) yaml_config.write_text(yaml_config_data, encoding="utf-8") - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=tmp_path): - result = runner.invoke(cli, ["sync", *sync_args]) - assert result.exit_code == expected_exit_code - output = "".join(list(result.output)) + monkeypatch.chdir(tmp_path) + + try: + cli(["sync", *sync_args]) + except SystemExit: + pass - if expected_in_output is not None: - if isinstance(expected_in_output, str): - expected_in_output = [expected_in_output] - for needle in expected_in_output: - assert needle in output + result = capsys.readouterr() + output = "".join(list(result.out)) - if expected_not_in_output is not None: - if isinstance(expected_not_in_output, str): - expected_not_in_output = [expected_not_in_output] - for needle in expected_not_in_output: - assert needle not in output + if expected_in_out is not None: + if isinstance(expected_in_out, str): + expected_in_out = [expected_in_out] + for needle in expected_in_out: + assert needle in output + + if expected_not_in_out is not None: + if isinstance(expected_not_in_out, str): + expected_not_in_out = [expected_not_in_out] + for needle in expected_not_in_out: + assert needle not in output class SyncFixture(t.NamedTuple): test_id: str sync_args: list[str] expected_exit_code: int - expected_in_output: "ExpectedOutput" = None - expected_not_in_output: "ExpectedOutput" = None + expected_in_out: "ExpectedOutput" = None + expected_not_in_out: "ExpectedOutput" = None + expected_in_err: "ExpectedOutput" = None + expected_not_in_err: "ExpectedOutput" = None SYNC_REPO_FIXTURES = [ @@ -114,63 +125,65 @@ class SyncFixture(t.NamedTuple): test_id="empty", sync_args=[], expected_exit_code=0, - expected_in_output=["Options:", "Commands:"], + expected_in_out=["{sync", "positional arguments:"], ), # Version SyncFixture( test_id="--version", sync_args=["--version"], expected_exit_code=0, - expected_in_output=[__version__, ", libvcs"], + expected_in_out=[__version__, ", libvcs"], ), SyncFixture( test_id="-V", sync_args=["-V"], expected_exit_code=0, - expected_in_output=[__version__, ", libvcs"], + expected_in_out=[__version__, ", libvcs"], ), # Help SyncFixture( test_id="--help", sync_args=["--help"], expected_exit_code=0, - expected_in_output=["Options:", "Commands:"], + expected_in_out=["{sync", "positional arguments:"], ), SyncFixture( test_id="-h", sync_args=["-h"], expected_exit_code=0, - expected_in_output=["Options:", "Commands:"], + expected_in_out=["{sync", "positional arguments:"], ), # Sync SyncFixture( test_id="sync--empty", sync_args=["sync"], - expected_exit_code=0, - expected_in_output="Options:", - expected_not_in_output="Commands:", + expected_exit_code=1, + expected_in_out=( + "sync: error: the following arguments are required: repo_terms" + ), + expected_not_in_out="positional arguments:", ), # Sync: Help SyncFixture( test_id="sync---help", sync_args=["sync", "--help"], expected_exit_code=0, - expected_in_output="Options:", - expected_not_in_output="Commands:", + expected_in_out=["repo_terms", "--exit-on-error"], + expected_not_in_out="--version", ), SyncFixture( test_id="sync--h", sync_args=["sync", "-h"], expected_exit_code=0, - expected_in_output="Options:", - expected_not_in_output="Commands:", + expected_in_out=["repo_terms", "--exit-on-error"], + expected_not_in_out="--version", ), # Sync: Repo terms SyncFixture( test_id="sync--one-repo-term", sync_args=["sync", "my_git_repo"], expected_exit_code=0, - expected_in_output="my_git_repo", + expected_in_out="my_git_repo", ), ] @@ -181,58 +194,66 @@ class SyncFixture(t.NamedTuple): ids=[test.test_id for test in SYNC_REPO_FIXTURES], ) def test_sync( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, config_path: pathlib.Path, - tmp_path: pathlib.Path, git_repo: GitSync, test_id: str, sync_args: list[str], expected_exit_code: int, - expected_in_output: "ExpectedOutput", - expected_not_in_output: "ExpectedOutput", + expected_in_out: "ExpectedOutput", + expected_not_in_out: "ExpectedOutput", + expected_in_err: "ExpectedOutput", + expected_not_in_err: "ExpectedOutput", ) -> None: - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=tmp_path): - config = { - "~/github_projects/": { - "my_git_repo": { - "url": f"git+file://{git_repo.dir}", - "remotes": {"test_remote": f"git+file://{git_repo.dir}"}, - }, - "broken_repo": { - "url": f"git+file://{git_repo.dir}", - "remotes": {"test_remote": "git+file://non-existent-remote"}, - }, - } + config = { + "~/github_projects/": { + "my_git_repo": { + "url": f"git+file://{git_repo.dir}", + "remotes": {"test_remote": f"git+file://{git_repo.dir}"}, + }, + "broken_repo": { + "url": f"git+file://{git_repo.dir}", + "remotes": {"test_remote": "git+file://non-existent-remote"}, + }, } - yaml_config = config_path / ".vcspull.yaml" - yaml_config_data = yaml.dump(config, default_flow_style=False) - yaml_config.write_text(yaml_config_data, encoding="utf-8") + } + yaml_config = config_path / ".vcspull.yaml" + yaml_config_data = yaml.dump(config, default_flow_style=False) + yaml_config.write_text(yaml_config_data, encoding="utf-8") + + # CLI can sync + try: + cli(sync_args) + except SystemExit: + pass - # CLI can sync - result = runner.invoke(cli, sync_args) - assert result.exit_code == expected_exit_code - output = "".join(list(result.output)) + result = capsys.readouterr() + output = "".join(list(result.out if expected_exit_code == 0 else result.err)) - if expected_in_output is not None: - if isinstance(expected_in_output, str): - expected_in_output = [expected_in_output] - for needle in expected_in_output: - assert needle in output + if expected_in_out is not None: + if isinstance(expected_in_out, str): + expected_in_out = [expected_in_out] + for needle in expected_in_out: + assert needle in output - if expected_not_in_output is not None: - if isinstance(expected_not_in_output, str): - expected_not_in_output = [expected_not_in_output] - for needle in expected_not_in_output: - assert needle not in output + if expected_not_in_out is not None: + if isinstance(expected_not_in_out, str): + expected_not_in_out = [expected_not_in_out] + for needle in expected_not_in_out: + assert needle not in output class SyncBrokenFixture(t.NamedTuple): test_id: str sync_args: list[str] expected_exit_code: int - expected_in_output: "ExpectedOutput" = None - expected_not_in_output: "ExpectedOutput" = None + expected_in_out: "ExpectedOutput" = None + expected_not_in_out: "ExpectedOutput" = None + expected_in_err: "ExpectedOutput" = None + expected_not_in_err: "ExpectedOutput" = None SYNC_BROKEN_REPO_FIXTURES = [ @@ -240,44 +261,44 @@ class SyncBrokenFixture(t.NamedTuple): test_id="normal-checkout", sync_args=["my_git_repo"], expected_exit_code=0, - expected_in_output="Already on 'master'", + expected_in_out="Already on 'master'", ), SyncBrokenFixture( test_id="normal-checkout--exit-on-error", sync_args=["my_git_repo", "--exit-on-error"], expected_exit_code=0, - expected_in_output="Already on 'master'", + expected_in_out="Already on 'master'", ), SyncBrokenFixture( test_id="normal-checkout--x", sync_args=["my_git_repo", "-x"], expected_exit_code=0, - expected_in_output="Already on 'master'", + expected_in_out="Already on 'master'", ), SyncBrokenFixture( test_id="normal-first-broken", sync_args=["my_git_repo_not_found", "my_git_repo"], expected_exit_code=0, - expected_not_in_output=EXIT_ON_ERROR_MSG, + expected_not_in_out=EXIT_ON_ERROR_MSG, ), SyncBrokenFixture( test_id="normal-last-broken", sync_args=["my_git_repo", "my_git_repo_not_found"], expected_exit_code=0, - expected_not_in_output=EXIT_ON_ERROR_MSG, + expected_not_in_out=EXIT_ON_ERROR_MSG, ), SyncBrokenFixture( test_id="exit-on-error--exit-on-error-first-broken", sync_args=["my_git_repo_not_found", "my_git_repo", "--exit-on-error"], expected_exit_code=1, - expected_in_output=EXIT_ON_ERROR_MSG, + expected_in_err=EXIT_ON_ERROR_MSG, ), SyncBrokenFixture( test_id="exit-on-error--x-first-broken", sync_args=["my_git_repo_not_found", "my_git_repo", "-x"], expected_exit_code=1, - expected_in_output=EXIT_ON_ERROR_MSG, - expected_not_in_output="master", + expected_in_err=EXIT_ON_ERROR_MSG, + expected_not_in_out="master", ), # # Verify ordering @@ -286,13 +307,15 @@ class SyncBrokenFixture(t.NamedTuple): test_id="exit-on-error--exit-on-error-last-broken", sync_args=["my_git_repo", "my_git_repo_not_found", "-x"], expected_exit_code=1, - expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"], + expected_in_out="Already on 'master'", + expected_in_err=EXIT_ON_ERROR_MSG, ), SyncBrokenFixture( test_id="exit-on-error--x-last-item", sync_args=["my_git_repo", "my_git_repo_not_found", "--exit-on-error"], expected_exit_code=1, - expected_in_output=[EXIT_ON_ERROR_MSG, "Already on 'master'"], + expected_in_out="Already on 'master'", + expected_in_err=EXIT_ON_ERROR_MSG, ), ] @@ -303,53 +326,72 @@ class SyncBrokenFixture(t.NamedTuple): ids=[test.test_id for test in SYNC_BROKEN_REPO_FIXTURES], ) def test_sync_broken( + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, config_path: pathlib.Path, - tmp_path: pathlib.Path, git_repo: GitSync, test_id: str, sync_args: list[str], expected_exit_code: int, - expected_in_output: "ExpectedOutput", - expected_not_in_output: "ExpectedOutput", + expected_in_out: "ExpectedOutput", + expected_not_in_out: "ExpectedOutput", + expected_in_err: "ExpectedOutput", + expected_not_in_err: "ExpectedOutput", ) -> None: - runner = CliRunner() - github_projects = user_path / "github_projects" my_git_repo = github_projects / "my_git_repo" if my_git_repo.is_dir(): shutil.rmtree(my_git_repo) - with runner.isolated_filesystem(temp_dir=tmp_path): - config = { - "~/github_projects/": { - "my_git_repo": { - "url": f"git+file://{git_repo.dir}", - "remotes": {"test_remote": f"git+file://{git_repo.dir}"}, - }, - "my_git_repo_not_found": { - "url": "git+file:///dev/null", - }, - } + config = { + "~/github_projects/": { + "my_git_repo": { + "url": f"git+file://{git_repo.dir}", + "remotes": {"test_remote": f"git+file://{git_repo.dir}"}, + }, + "my_git_repo_not_found": { + "url": "git+file:///dev/null", + }, } - yaml_config = config_path / ".vcspull.yaml" - yaml_config_data = yaml.dump(config, default_flow_style=False) - yaml_config.write_text(yaml_config_data, encoding="utf-8") - - # CLI can sync - assert isinstance(sync_args, list) - result = runner.invoke(cli, ["sync", *sync_args]) - assert result.exit_code == expected_exit_code - output = "".join(list(result.output)) - - if expected_in_output is not None: - if isinstance(expected_in_output, str): - expected_in_output = [expected_in_output] - for needle in expected_in_output: - assert needle in output - - if expected_not_in_output is not None: - if isinstance(expected_not_in_output, str): - expected_not_in_output = [expected_not_in_output] - for needle in expected_not_in_output: - assert needle not in output + } + yaml_config = config_path / ".vcspull.yaml" + yaml_config_data = yaml.dump(config, default_flow_style=False) + yaml_config.write_text(yaml_config_data, encoding="utf-8") + + # CLI can sync + assert isinstance(sync_args, list) + + try: + cli(["sync", *sync_args]) + except SystemExit: + pass + + result = capsys.readouterr() + out = "".join(list(result.out)) + err = "".join(list(result.err)) + + if expected_in_out is not None: + if isinstance(expected_in_out, str): + expected_in_out = [expected_in_out] + for needle in expected_in_out: + assert needle in out + + if expected_not_in_out is not None: + if isinstance(expected_not_in_out, str): + expected_not_in_out = [expected_not_in_out] + for needle in expected_not_in_out: + assert needle not in out + + if expected_in_err is not None: + if isinstance(expected_in_err, str): + expected_in_err = [expected_in_err] + for needle in expected_in_err: + assert needle in err + + if expected_not_in_err is not None: + if isinstance(expected_not_in_err, str): + expected_not_in_err = [expected_not_in_err] + for needle in expected_not_in_err: + assert needle not in err