From b23e6d55dad19745fa7bcd694f46bc3c866897c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Aug 2022 11:21:28 +0200 Subject: [PATCH 1/9] build: Add monkeytype_{create,apply} to Makefile --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index cada1a867a5..239f44adefb 100644 --- a/Makefile +++ b/Makefile @@ -58,3 +58,9 @@ watch_mypy: format_markdown: npx prettier --parser=markdown -w *.md docs/*.md docs/**/*.md CHANGES + +monkeytype_create: + poetry run monkeytype run `poetry run which py.test` + +monkeytype_apply: + poetry run monkeytype list-modules | xargs -n1 -I{} sh -c 'poetry run monkeytype apply {}' From 969359cf36af750ef594897f1de708b6ee599fc1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Oct 2022 10:15:58 -0500 Subject: [PATCH 2/9] build(deps): Remove click --- poetry.lock | 10 +++++----- pyproject.toml | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3610394a994..707e109f4cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -100,7 +100,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" @@ -250,7 +250,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "importlib-metadata" version = "4.13.0" description = "Read metadata from Python packages" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -969,7 +969,7 @@ python-versions = "*" name = "typing-extensions" version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -1001,7 +1001,7 @@ watchmedo = ["PyYAML (>=3.10)"] name = "zipp" version = "3.9.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -1019,7 +1019,7 @@ test = [] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "02bbfa0e2fd180baa58c1474307f15ecdc4304156e52dce31bbce4c7120c4140" +content-hash = "447dd22cdbf32d0450e40ed67b5b44f70ad6384922b98ff024a10c41dbfa7c0b" [metadata.files] aafigure = [ diff --git a/pyproject.toml b/pyproject.toml index 33143ba9666..3267d76d187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ tmuxp = 'tmuxp:cli.cli' [tool.poetry.dependencies] python = "^3.7" -click = "~8" libtmux = "~0.15.8" colorama = ">=0.3.9" PyYAML = "^6.0" From 30838dc13828624b754d92ddbace2ba7233e68e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 2 Oct 2022 10:16:42 -0500 Subject: [PATCH 3/9] build(deps): sphinx-click -> sphinx-argparse --- poetry.lock | 37 +++++++++++++++++++------------------ pyproject.toml | 4 ++-- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/poetry.lock b/poetry.lock index 707e109f4cc..e36e6495d4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -735,6 +735,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.2" +description = "A sphinx extension that automatically documents argparse commands and options" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +sphinx = ">=1.2.0" + +[package.extras] +markdown = ["CommonMark (>=0.5.6)"] + [[package]] name = "sphinx-autobuild" version = "2021.3.14" @@ -781,19 +795,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" @@ -1019,7 +1020,7 @@ test = [] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "447dd22cdbf32d0450e40ed67b5b44f70ad6384922b98ff024a10c41dbfa7c0b" +content-hash = "44f3d12d61307a3b9811ff20816c0860900e29399af5d0dd5823d28dd65b5ded" [metadata.files] aafigure = [ @@ -1471,6 +1472,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.2.tar.gz", hash = "sha256:e54ad6c8f895ac6bb9d0dd9fa07e47e137a2a42ae18a714515262e86b4cc4bab"}, + {file = "sphinx_argparse-0.3.2-py3-none-any.whl", hash = "sha256:499afb62d19966651e1e5d601ac912ca2bdfb43f5dad491305d84bef4414f4f4"}, +] 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"}, @@ -1483,10 +1488,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 3267d76d187..b7b41d686e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ furo = "*" gp-libs = "0.0.1a16" sphinx-autobuild = "*" sphinx-autodoc-typehints = "*" -sphinx-click = "*" +sphinx-argparse = "*" sphinx-inline-tabs = "*" sphinxext-opengraph = "*" sphinx-copybutton = "*" @@ -101,7 +101,7 @@ importlib-metadata = "<5" # https://github.com/PyCQA/flake8/issues/1701 docs = [ "docutils", "sphinx", - "sphinx-click", + "sphinx-argparse", "sphinx-autodoc-typehints", "sphinx-autobuild", "sphinx-copybutton", From f0e2b6ec6756d3c38035e26f7fdbabc1c606d1c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Oct 2022 11:57:47 -0500 Subject: [PATCH 4/9] docs(conf): Move over to sphinx-argparse --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 066e67feac5..1218c7c1cdd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ "sphinx.ext.napoleon", "sphinx.ext.linkcode", "aafig", - "sphinx_click.ext", # sphinx-click + "sphinxarg.ext", # sphinx-argparse "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", From 25c83f968fbea8729ab3b9cf9da0533822fdf214 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 9 Oct 2022 10:43:18 -0500 Subject: [PATCH 5/9] docs(conf): Remove click from intersphinx --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 1218c7c1cdd..7a28c3f3c9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -164,7 +164,6 @@ intersphinx_mapping = { "python": ("https://docs.python.org/", None), "libtmux": ("https://libtmux.git-pull.com/", None), - "click": ("http://click.pocoo.org/5", None), } # aafig format, try to get working with pdf From 843b4cbb938205852200c6610826bcdedb76bcf9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 8 Oct 2022 12:23:23 -0500 Subject: [PATCH 6/9] docs(CLI): Update to argparse --- docs/cli/completion.md | 49 +++++++++++++++++++++++++++++++++++++++++- docs/cli/convert.md | 19 ++++++++-------- docs/cli/debug-info.md | 19 ++++++++-------- docs/cli/edit.md | 11 ++++++---- docs/cli/freeze.md | 21 ++++++++++-------- docs/cli/import.md | 16 ++++++++------ docs/cli/index.md | 21 +++++++++++++++--- docs/cli/load.md | 23 ++++++++++---------- docs/cli/ls.md | 13 +++++++---- docs/cli/shell.md | 13 +++++------ 10 files changed, 143 insertions(+), 62 deletions(-) diff --git a/docs/cli/completion.md b/docs/cli/completion.md index ce59232e625..1de10869a86 100644 --- a/docs/cli/completion.md +++ b/docs/cli/completion.md @@ -1,11 +1,58 @@ (completion)= -# Completion +(completions)= + +(cli-completions)= + +# Completions + +## tmuxp 1.17+ (experimental) + +```{note} +See the [shtab library's documentation on shell completion](https://docs.iterative.ai/shtab/use/#cli-usage) for the most up to date way of connecting completion for tmuxp. +``` + +Provisional support for completions in tmuxp 1.17+ are powered by [shtab](https://docs.iterative.ai/shtab/). This must be **installed separately**, as it's **not currently bundled with tmuxp**. + +```console +$ pip install shtab --user +``` + +:::{tab} bash + +```bash +shtab --shell=bash -u tmuxp.cli.create_parser \ + | sudo tee "$BASH_COMPLETION_COMPAT_DIR"/TMUXP +``` + +::: + +:::{tab} zsh + +```zsh +shtab --shell=zsh -u tmuxp.cli.create_parser \ + | sudo tee /usr/local/share/zsh/site-functions/_TMUXP +``` + +::: + +:::{tab} tcsh + +```zsh +shtab --shell=tcsh -u tmuxp.cli.create_parser \ + | sudo tee /etc/profile.d/TMUXP.completion.csh +``` + +::: + +## tmuxp 1.1 to 1.16 ```{note} See the [click library's documentation on shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion/) for the most up to date way of connecting completion for tmuxp. ``` +tmuxp 1.1 to 1.16 use [click](https://click.palletsprojects.com)'s completion: + :::{tab} Bash _~/.bashrc_: diff --git a/docs/cli/convert.md b/docs/cli/convert.md index cb1ff2c72b5..082f82abd65 100644 --- a/docs/cli/convert.md +++ b/docs/cli/convert.md @@ -4,6 +4,16 @@ Convert between YAML and JSON +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: convert +``` + +## Usage + ````{tab} YAML -> JSON ```console @@ -22,12 +32,3 @@ $ tmuxp convert /path/to/file.json tmuxp automatically will prompt to convert `.yaml` to `.json` and `.json` to `.yaml`. - -## Reference - -```{eval-rst} -.. click:: tmuxp.cli.convert:command_convert - :prog: tmuxp convert - :commands: convert - :nested: full -``` diff --git a/docs/cli/debug-info.md b/docs/cli/debug-info.md index cd8298d8c88..5bb4fd4b62b 100644 --- a/docs/cli/debug-info.md +++ b/docs/cli/debug-info.md @@ -7,6 +7,16 @@ Use to collect all relevant information for submitting an issue to the project. +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: debug-info +``` + +## Usage + ```console $ tmuxp debug-info @@ -17,12 +27,3 @@ environment: ... ``` - -## Reference - -```{eval-rst} -.. click:: tmuxp.cli.debug_info:command_debug_info - :prog: tmuxp debug-info - :commands: debug-info - :nested: full -``` diff --git a/docs/cli/edit.md b/docs/cli/edit.md index 89735f6455f..323426931b8 100644 --- a/docs/cli/edit.md +++ b/docs/cli/edit.md @@ -1,10 +1,13 @@ (edit-config)= +(cli-edit)= + # tmuxp edit ```{eval-rst} -.. click:: tmuxp.cli.edit:command_edit - :prog: tmuxp edit - :commands: edit - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: edit ``` diff --git a/docs/cli/freeze.md b/docs/cli/freeze.md index 414ac8e8697..63d0dc0625b 100644 --- a/docs/cli/freeze.md +++ b/docs/cli/freeze.md @@ -1,7 +1,19 @@ (cli-freeze)= +(cli-freeze-reference)= + # tmuxp freeze +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: freeze +``` + +## Usage + Freeze sessions ```console @@ -23,12 +35,3 @@ Tmuxp will offer to save your session state to `.json` or `.yaml`. If no session is specified, it will default to the attached session. If the `--force` argument is passed, it will overwrite any existing config file with the same name. - -(cli-freeze-reference)= - -```{eval-rst} -.. click:: tmuxp.cli.freeze:command_freeze - :prog: tmuxp freeze - :commands: freeze - :nested: full -``` diff --git a/docs/cli/import.md b/docs/cli/import.md index 4cffaed6bd6..c90ad4cd4e7 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -7,9 +7,11 @@ ## From teamocil ```{eval-rst} -.. click:: tmuxp.cli.import_config:command_import_teamocil - :prog: tmuxp import teamocil - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import teamocil ``` ````{tab} YAML @@ -33,9 +35,11 @@ $ tmuxp import teamocil /path/to/file.json ## From tmuxinator ```{eval-rst} -.. click:: tmuxp.cli.import_config:command_import_tmuxinator - :prog: tmuxp import tmuxinator - :nested: short +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: import tmuxinator ``` ````{tab} YAML diff --git a/docs/cli/index.md b/docs/cli/index.md index 0abfe3676f0..3205cccc3b3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -21,7 +21,6 @@ edit import convert freeze - ``` ```{toctree} @@ -29,12 +28,28 @@ freeze :maxdepth: 1 debug-info - ``` ```{toctree} -:caption: More +:caption: Completion :maxdepth: 1 completion ``` + +(cli-main)= + +(tmuxp-main)= + +## Command: `tmuxp` + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :nosubcommands: + + subparser_name : @replace + See :ref:`cli-ls` +``` diff --git a/docs/cli/load.md b/docs/cli/load.md index 86c62630803..f12e6a7177c 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -2,8 +2,20 @@ (tmuxp-load)= +(tmuxp-load-reference)= + # tmuxp load +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: load +``` + +## Usage + You can load your tmuxp file and attach the vim session via a few shorthands: @@ -136,14 +148,3 @@ $ tmuxp load [filename] --log-file [log_filename] ```console $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] ``` - -## Reference - -(tmuxp-load-reference)= - -```{eval-rst} -.. click:: tmuxp.cli.load:command_load - :prog: tmuxp load - :commands: load - :nested: full -``` diff --git a/docs/cli/ls.md b/docs/cli/ls.md index f46d4593f33..1b0ba3b6437 100644 --- a/docs/cli/ls.md +++ b/docs/cli/ls.md @@ -1,10 +1,15 @@ +(cli-ls)= + (ls-config)= # tmuxp ls +List sesssions. + ```{eval-rst} -.. click:: tmuxp.cli.ls:command_ls - :prog: tmuxp ls - :commands: ls - :nested: full +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: ls ``` diff --git a/docs/cli/shell.md b/docs/cli/shell.md index 9843f9ada2a..b202fdaae89 100644 --- a/docs/cli/shell.md +++ b/docs/cli/shell.md @@ -1,12 +1,15 @@ (cli-shell)= +(tmuxp-shell)= + # tmuxp shell ```{eval-rst} -.. click:: tmuxp.cli.shell:command_shell - :prog: tmuxp shell - :commands: shell - :nested: none +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: shell ``` ## Directly enter commands @@ -15,8 +18,6 @@ $ tmuxp shell -c 'python code' ``` -## Example - ```{image} ../_static/tmuxp-shell.gif :width: 100% ``` From 36d8a0ed7a96a287fd3beffc41316fa114b80347 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 9 Oct 2022 10:42:56 -0500 Subject: [PATCH 7/9] refactor!(click -> argparse) --- src/tmuxp/cli/__init__.py | 178 ++++++++++++--- src/tmuxp/cli/convert.py | 63 +++-- src/tmuxp/cli/debug_info.py | 21 +- src/tmuxp/cli/edit.py | 27 ++- src/tmuxp/cli/freeze.py | 113 +++++---- src/tmuxp/cli/import_config.py | 174 ++++++++------ src/tmuxp/cli/load.py | 219 +++++++++++------- src/tmuxp/cli/ls.py | 18 +- src/tmuxp/cli/shell.py | 150 ++++++++---- src/tmuxp/cli/utils.py | 266 ++++++++++++++++++++-- tests/test_cli.py | 405 +++++++++++++++++++++------------ 11 files changed, 1151 insertions(+), 483 deletions(-) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 010e21a6f66..9aa8f2d2d49 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -4,50 +4,92 @@ ~~~~~~~~~ """ +import argparse import logging import os import sys -import click - from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import has_minimum_version from libtmux.exc import TmuxCommandNotFound -from tmuxp.cli.ls import command_ls from .. import exc from ..__about__ import __version__ from ..log import setup_logger -from .convert import command_convert -from .debug_info import command_debug_info -from .edit import command_edit -from .freeze import command_freeze -from .import_config import command_import -from .load import command_load -from .shell import command_shell +from .convert import command_convert, create_convert_subparser +from .debug_info import command_debug_info, create_debug_info_subparser +from .edit import command_edit, create_edit_subparser +from .freeze import command_freeze, create_freeze_subparser +from .import_config import ( + command_import_teamocil, + command_import_tmuxinator, + create_import_subparser, +) +from .load import command_load, create_load_subparser +from .ls import command_ls, create_ls_subparser +from .shell import command_shell, create_shell_subparser from .utils import tmuxp_echo logger = logging.getLogger(__name__) -@click.group(context_settings={"obj": {}, "help_option_names": ["-h", "--help"]}) -@click.version_option( - __version__, - "-V", - "--version", - message=f"%(prog)s %(version)s, libtmux {libtmux_version}", -) -@click.option( - "--log-level", - default="INFO", - help="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -def cli(log_level): +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="tmuxp") + parser.add_argument( + "--version", + "-V", + action="version", + version=f"%(prog)s {__version__}, libtmux {libtmux_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") + load_parser = subparsers.add_parser("load", help="load tmuxp workspaces") + create_load_subparser(load_parser) + shell_parser = subparsers.add_parser( + "shell", help="launch python shell for tmux server, session, window and pane" + ) + create_shell_subparser(shell_parser) + import_parser = subparsers.add_parser( + "import", help="import configurations from teamocil and tmuxinator." + ) + create_import_subparser(import_parser) + + convert_parser = subparsers.add_parser( + "convert", help="convert configs between yaml and json." + ) + create_convert_subparser(convert_parser) + + debug_info_parser = subparsers.add_parser( + "debug-info", help="print out all diagnostic info" + ) + create_debug_info_subparser(debug_info_parser) + + ls_parser = subparsers.add_parser("ls", help="list sessions in config directory") + create_ls_subparser(ls_parser) + + edit_parser = subparsers.add_parser("edit", help="run $EDITOR on config") + create_edit_subparser(edit_parser) + + freeze_parser = subparsers.add_parser( + "freeze", help="freeze a live tmux session to a tmuxp config" + ) + create_freeze_subparser(freeze_parser) + + return parser + + +def cli(args=None): """Manage tmux sessions. Pass the "--help" argument to any command to see detailed help. See detailed documentation and examples at: http://tmuxp.git-pull.com/""" + try: has_minimum_version() except TmuxCommandNotFound: @@ -56,7 +98,84 @@ def cli(log_level): except exc.TmuxpException as e: tmuxp_echo(e, err=True) sys.exit() - setup_logger(logger=logger, level=log_level.upper()) + + parser = create_parser() + args = parser.parse_args(args) + + setup_logger(logger=logger, level=args.log_level.upper()) + + if args.subparser_name is None: + parser.print_help() + return + elif args.subparser_name == "load": + command_load( + config_file=args.config_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + tmux_config_file=args.tmux_config_file, + new_session_name=args.new_session_name, + answer_yes=args.answer_yes, + detached=args.detached, + append=args.append, + colors=args.colors, + log_file=args.log_file, + parser=parser, + ) + elif args.subparser_name == "shell": + command_shell( + session_name=args.session_name, + window_name=args.window_name, + socket_name=args.socket_name, + socket_path=args.socket_path, + command=args.command, + shell=args.shell, + use_pythonrc=args.use_pythonrc, + use_vi_mode=args.use_vi_mode, + parser=parser, + ) + elif args.subparser_name == "import": + import_subparser_name = getattr(args, "import_subparser_name", None) + if import_subparser_name is None: + parser.print_help() + return + elif import_subparser_name == "teamocil": + command_import_teamocil( + config_file=args.config_file, + parser=parser, + ) + elif import_subparser_name == "tmuxinator": + command_import_tmuxinator( + config_file=args.config_file, + parser=parser, + ) + elif args.subparser_name == "convert": + command_convert( + config_file=args.config_file, + answer_yes=args.answer_yes, + parser=parser, + ) + elif args.subparser_name == "debug-info": + command_debug_info(parser=parser) + + elif args.subparser_name == "edit": + command_edit( + config_file=args.config_file, + parser=parser, + ) + elif args.subparser_name == "freeze": + command_freeze( + session_name=args.session_name, + socket_name=args.socket_name, + socket_path=args.socket_path, + config_format=args.config_format, + save_to=args.save_to, + answer_yes=args.answer_yes, + quiet=args.quiet, + force=args.force, + parser=parser, + ) + elif args.subparser_name == "ls": + command_ls(parser=parser) def startup(config_dir): @@ -70,14 +189,3 @@ def startup(config_dir): if not os.path.exists(config_dir): os.makedirs(config_dir) - - -# Register sub-commands here -cli.add_command(command_convert) -cli.add_command(command_edit) -cli.add_command(command_debug_info) -cli.add_command(command_load) -cli.add_command(command_ls) -cli.add_command(command_freeze) -cli.add_command(command_shell) -cli.add_command(command_import) diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 2e9d2b09114..12d2cfcd8cb 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -1,43 +1,64 @@ +import argparse import os import pathlib - -import click +import typing as t from tmuxp.config_reader import ConfigReader -from .utils import ConfigPath +from .utils import get_config_dir, prompt_yes_no, scan_config + +def create_convert_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + parser.add_argument( + dest="config_file", + type=str, + metavar="config-file", + help="checks tmuxp and current directory for config files.", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + return parser -@click.command(name="convert") -@click.option( - "--yes", "-y", "confirmed", help='Auto confirms with "yes".', is_flag=True -) -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_convert(confirmed, config): + +def command_convert( + config_file: t.Union[str, pathlib.Path], + answer_yes: bool, + parser: t.Optional[argparse.ArgumentParser] = None, +): """Convert a tmuxp config between JSON and YAML.""" + config_file = scan_config(config_file, config_dir=get_config_dir()) + + if isinstance(config_file, str): + config_file = pathlib.Path(config_file) - _, ext = os.path.splitext(config) + _, ext = os.path.splitext(config_file) ext = ext.lower() if ext == ".json": to_filetype = "yaml" elif ext in [".yaml", ".yml"]: to_filetype = "json" else: - raise click.BadParameter( - f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])" - ) + raise Exception(f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])") - configparser = ConfigReader.from_file(pathlib.Path(config)) - newfile = config.replace(ext, f".{to_filetype}") + configparser = ConfigReader.from_file(config_file) + newfile = config_file.parent / (str(config_file.stem) + f".{to_filetype}") - new_config = configparser.dump(format=to_filetype) + export_kwargs = {"default_flow_style": False} if to_filetype == "yaml" else {} + new_config = configparser.dump(format=to_filetype, indent=2, **export_kwargs) - if not confirmed: - if click.confirm(f"convert to <{config}> to {to_filetype}?"): - if click.confirm(f"Save config to {newfile}?"): - confirmed = True + if not answer_yes: + if prompt_yes_no(f"Convert to <{config_file}> to {to_filetype}?"): + if prompt_yes_no("Save config to %s?" % newfile): + answer_yes = True - if confirmed: + if answer_yes: buf = open(newfile, "w") buf.write(new_config) buf.close() diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index ffaf97be9f3..4dba55303e1 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -1,12 +1,14 @@ +import argparse import os import pathlib import platform import shutil import sys +import typing as t -import click +from colorama import Fore -from libtmux import __version__ as libtmux_version +from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import get_version, tmux_cmd from ..__about__ import __version__ @@ -15,8 +17,15 @@ tmuxp_path = pathlib.Path(__file__).parent.parent -@click.command(name="debug-info", short_help="Print out all diagnostic info") -def command_debug_info(): +def create_debug_info_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + return parser + + +def command_debug_info( + parser: t.Optional[argparse.ArgumentParser] = None, +): """ Print debug info to submit with Issues. """ @@ -40,7 +49,9 @@ def format_tmux_resp(std_resp): return "\n".join( [ "\n".join(prepend_tab(std_resp.stdout)), - click.style("\n".join(prepend_tab(std_resp.stderr)), fg="red"), + Fore.RED, + "\n".join(prepend_tab(std_resp.stderr)), + Fore.RESET, ] ) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 0eb9c95a5fc..b2f2988b9b4 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -1,15 +1,28 @@ +import argparse import os +import pathlib import subprocess +import typing as t -import click +from .utils import scan_config -from .utils import ConfigPath, scan_config +def create_edit_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + parser.add_argument( + dest="config_file", + type=str, + help="checks current tmuxp and current directory for yaml files.", + ) + return parser -@click.command(name="edit", short_help="Run $EDITOR on config.") -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_edit(config): - config = scan_config(config) + +def command_edit( + config_file: t.Union[str, pathlib.Path], + parser: t.Optional[argparse.ArgumentParser] = None, +): + config_file = scan_config(config_file) sys_editor = os.environ.get("EDITOR", "vim") - subprocess.call([sys_editor, config]) + subprocess.call([sys_editor, config_file]) diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 081afdfa6b1..4646a156481 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -1,7 +1,8 @@ +import argparse import os +import pathlib import sys - -import click +import typing as t from libtmux.server import Server from tmuxp.config_reader import ConfigReader @@ -9,7 +10,7 @@ from .. import config, util from ..workspacebuilder import freeze -from .utils import _validate_choices, get_abs_path, get_config_dir +from .utils import _validate_choices, get_config_dir, prompt, prompt_yes_no def session_completion(ctx, params, incomplete): @@ -18,39 +19,66 @@ def session_completion(ctx, params, incomplete): return sorted(str(c) for c in choices if str(c).startswith(incomplete)) -@click.command(name="freeze") -@click.argument( - "session_name", nargs=1, required=False, shell_complete=session_completion -) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option( - "-f", - "--config-format", - type=click.Choice(["yaml", "json"]), - help="format to save in", -) -@click.option("-o", "--save-to", type=click.Path(exists=False), help="file to save to") -@click.option( - "-y", - "--yes", - type=bool, - is_flag=True, - default=False, - help="Don't prompt for confirmation", -) -@click.option( - "-q", - "--quiet", - type=bool, - is_flag=True, - default=False, - help="Don't prompt for confirmation", -) -@click.option("--force", "force", help="overwrite the config file", is_flag=True) +def create_freeze_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + parser.add_argument( + dest="session_name", + metavar="session-name", + nargs="?", + action="store", + ) + parser.add_argument( + "-S", dest="socket_path", metavar="socket-path", help="pass-through for tmux -S" + ) + parser.add_argument( + "-L", dest="socket_name", metavar="socket-name", help="pass-through for tmux -L" + ) + parser.add_argument( + "-f", + "--config-format", + choices=["yaml", "json"], + help="format to save in", + ) + parser.add_argument( + "-o", + "--save-to", + metavar="output-path", + type=pathlib.Path, + help="file to save to", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "--quiet", + "-q", + dest="quiet", + action="store_true", + help="don't prompt for confirmation", + ) + parser.add_argument( + "--force", dest="force", action="store_true", help="overwrite the config file" + ) + + return parser + + def command_freeze( - session_name, socket_name, config_format, save_to, socket_path, yes, quiet, force -): + session_name: t.Optional[str] = None, + socket_name: t.Optional[str] = None, + config_format: t.Optional[str] = None, + save_to: t.Optional[str] = None, + socket_path: t.Optional[str] = None, + answer_yes: t.Optional[bool] = None, + quiet: t.Optional[bool] = None, + force: t.Optional[bool] = None, + parser: t.Optional[argparse.ArgumentParser] = None, +) -> None: """Snapshot a session into a config. If SESSION_NAME is provided, snapshot that session. Otherwise, use the @@ -81,8 +109,8 @@ def command_freeze( "Freeze does its best to snapshot live tmux sessions.\n" ) if not ( - yes - or click.confirm( + answer_yes + or prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?" ) ): @@ -102,8 +130,9 @@ def command_freeze( "{}.{}".format(sconf.get("session_name"), config_format or "yaml"), ) ) - dest_prompt = click.prompt( - "Save to: %s" % save_to, value_proc=get_abs_path, default=save_to + dest_prompt = prompt( + "Save to: %s" % save_to, + default=save_to, ) if not force and os.path.exists(dest_prompt): print("%s exists. Pick a new filename." % dest_prompt) @@ -112,12 +141,12 @@ def command_freeze( dest = dest_prompt dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) - if config_format is None: + if config_format is None or config_format == "": valid_config_formats = ["json", "yaml"] _, config_format = os.path.splitext(dest) config_format = config_format[1:].lower() if config_format not in valid_config_formats: - config_format = click.prompt( + config_format = prompt( "Couldn't ascertain one of [%s] from file name. Convert to" % ", ".join(valid_config_formats), value_proc=_validate_choices(["yaml", "json"]), @@ -131,7 +160,7 @@ def command_freeze( elif config_format == "json": newconfig = configparser.dump(format="json", indent=2) - if yes or click.confirm("Save to %s?" % dest): + if answer_yes or prompt_yes_no("Save to %s?" % dest): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index f88320f7087..9c2a9f8fe4c 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -1,13 +1,20 @@ +import argparse import os import pathlib import sys - -import click +import typing as t from tmuxp.config_reader import ConfigReader from .. import config -from .utils import ConfigPath, _validate_choices, get_abs_path, tmuxp_echo +from .utils import ( + get_abs_path, + prompt, + prompt_choices, + prompt_yes_no, + scan_config, + tmuxp_echo, +) def get_tmuxinator_dir(): @@ -50,27 +57,79 @@ def get_teamocil_dir(): def _resolve_path_no_overwrite(config): path = get_abs_path(config) if os.path.exists(path): - raise click.exceptions.UsageError("%s exists. Pick a new filename." % path) + raise ValueError("%s exists. Pick a new filename." % path) return path -@click.group(name="import") -def command_import(): +def command_import( + config_file: str, + print_list: str, + parser: argparse.ArgumentParser, +): """Import a teamocil/tmuxinator config.""" -def import_config(configfile, importfunc): - existing_config = ConfigReader._from_file(pathlib.Path(configfile)) - new_config = ConfigReader(importfunc(existing_config)) +def create_import_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + importsubparser = parser.add_subparsers( + title="commands", description="valid commands", help="additional help" + ) - config_format = click.prompt( - "Convert to", value_proc=_validate_choices(["yaml", "json"]), default="yaml" + import_teamocil = importsubparser.add_parser( + "teamocil", help="convert and import a teamocil config" + ) + + import_teamocilgroup = import_teamocil.add_mutually_exclusive_group(required=True) + import_teamocilgroup.add_argument( + dest="config_file", + type=str, + nargs="?", + metavar="config-file", + help="checks current ~/.teamocil and current directory for yaml files", + ) + import_teamocil.set_defaults( + callback=command_import_teamocil, import_subparser_name="teamocil" + ) + + import_tmuxinator = importsubparser.add_parser( + "tmuxinator", help="convert and import a tmuxinator config" + ) + + import_tmuxinatorgroup = import_tmuxinator.add_mutually_exclusive_group( + required=True + ) + import_tmuxinatorgroup.add_argument( + dest="config_file", + type=str, + nargs="?", + metavar="config-file", + help="checks current ~/.tmuxinator and current directory for yaml files", + ) + + import_tmuxinator.set_defaults( + callback=command_import_tmuxinator, import_subparser_name="tmuxinator" + ) + + return parser + + +def import_config( + config_file, + importfunc, + parser: t.Optional[argparse.ArgumentParser] = None, +): + existing_config = ConfigReader._from_file(pathlib.Path(config_file)) + cfg_reader = ConfigReader(importfunc(existing_config)) + + config_format = prompt_choices( + "Convert to", choices=["yaml", "json"], default="yaml" ) if config_format == "yaml": - new_config = new_config.dump("yaml", indent=2, default_flow_style=False) + new_config = cfg_reader.dump("yaml", indent=2, default_flow_style=False) elif config_format == "json": - new_config = new_config.dump("json", indent=2) + new_config = cfg_reader.dump("json", indent=2) else: sys.exit("Unknown config format.") @@ -79,17 +138,17 @@ def import_config(configfile, importfunc): "\n" "Configuration import does its best to convert files.\n" ) - if click.confirm( + if prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?" ): dest = None while not dest: - dest_path = click.prompt( + dest_path = prompt( "Save to [%s]" % os.getcwd(), value_proc=_resolve_path_no_overwrite ) # dest = dest_prompt - if click.confirm("Save to %s?" % dest_path): + if prompt_yes_no("Save to %s?" % dest_path): dest = dest_path buf = open(dest, "w") @@ -106,63 +165,40 @@ def import_config(configfile, importfunc): sys.exit() -@command_import.command( - name="tmuxinator", short_help="Convert and import a tmuxinator config." -) -@click.argument( - "configfile", type=ConfigPath(exists=True, config_dir=get_tmuxinator_dir), nargs=1 -) -def command_import_tmuxinator(configfile): - """Convert a tmuxinator config from CONFIGFILE to tmuxp format and import +def command_import_tmuxinator( + config_file: str, + parser: t.Optional[argparse.ArgumentParser] = None, +): + """Convert a tmuxinator config from config_file to tmuxp format and import it into tmuxp.""" - import_config(configfile, config.import_tmuxinator) - - -@click.command(name="convert") -@click.option( - "--yes", "-y", "confirmed", help='Auto confirms with "yes".', is_flag=True -) -@click.argument("config", type=ConfigPath(exists=True), nargs=1) -def command_convert(confirmed, config): - """Convert a tmuxp config between JSON and YAML.""" - - _, ext = os.path.splitext(config) - ext = ext.lower() - if ext == ".json": - to_filetype = "yaml" - elif ext in [".yaml", ".yml"]: - to_filetype = "json" - else: - raise click.BadParameter( - f"Unknown filetype: {ext} (valid: [.json, .yaml, .yml])" - ) + config_file = scan_config(config_file, config_dir=get_tmuxinator_dir()) + import_config(config_file, config.import_tmuxinator) - configparser = ConfigReader.from_file(config) - newfile = config.replace(ext, ".%s" % to_filetype) - export_kwargs = {"default_flow_style": False} if to_filetype == "yaml" else {} - new_config = configparser.dump(format=to_filetype, indent=2, **export_kwargs) - - if not confirmed: - if click.confirm(f"convert to <{config}> to {to_filetype}?"): - if click.confirm("Save config to %s?" % newfile): - confirmed = True - - if confirmed: - buf = open(newfile, "w") - buf.write(new_config) - buf.close() - print("New config saved to <%s>." % newfile) +def create_convert_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + parser.add_argument( + dest="config_file", + type=str, + help="checks current ~/.teamocil and current directory for yaml files", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + return parser -@command_import.command( - name="teamocil", short_help="Convert and import a teamocil config." -) -@click.argument( - "configfile", type=ConfigPath(exists=True, config_dir=get_teamocil_dir), nargs=1 -) -def command_import_teamocil(configfile): - """Convert a teamocil config from CONFIGFILE to tmuxp format and import +def command_import_teamocil( + config_file: str, + parser: t.Optional[argparse.ArgumentParser] = None, +): + """Convert a teamocil config from config_file to tmuxp format and import it into tmuxp.""" + config_file = scan_config(config_file, config_dir=get_teamocil_dir()) - import_config(configfile, config.import_teamocil) + import_config(config_file, config.import_teamocil) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 04c512cccd7..6d17cd00326 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -1,18 +1,17 @@ """Command line tool for managing tmux workspaces and tmuxp configurations. -tmuxp.cli -~~~~~~~~~ +tmuxp.cli.load +~~~~~~~~~~~~~~ """ +import argparse import importlib import logging import os import pathlib import shutil import sys -from typing import List - -import click +import typing as t from libtmux.common import has_gte_version from libtmux.server import Server @@ -20,7 +19,14 @@ from .. import config, exc, log, util from ..workspacebuilder import WorkspaceBuilder -from .utils import ConfigPath, _validate_choices, get_config_dir, tmuxp_echo +from .utils import ( + get_config_dir, + prompt_choices, + prompt_yes_no, + scan_config, + style, + tmuxp_echo, +) def set_layout_hook(session, hook_name): @@ -98,21 +104,21 @@ def load_plugins(sconf): plugin = getattr(importlib.import_module(module_name), plugin_name) plugins.append(plugin()) except exc.TmuxpPluginException as error: - if not click.confirm( + if not prompt_yes_no( "%sSkip loading %s?" - % (click.style(str(error), fg="yellow"), plugin_name), + % (style(str(error), fg="yellow"), plugin_name), default=True, ): - click.echo( - click.style("[Not Skipping] ", fg="yellow") + tmuxp_echo( + style("[Not Skipping] ", fg="yellow") + "Plugin versions constraint not met. Exiting..." ) sys.exit(1) except Exception as error: - click.echo( - click.style("[Plugin Error] ", fg="red") + tmuxp_echo( + style("[Plugin Error] ", fg="red") + f"Couldn't load {plugin}\n" - + click.style(f"{error}", fg="yellow") + + style(f"{error}", fg="yellow") ) sys.exit(1) @@ -337,12 +343,12 @@ def load_workspace( config_file = pathlib.Path(config_file) tmuxp_echo( - click.style("[Loading] ", fg="green") - + click.style(str(config_file), fg="blue", bold=True) + style("[Loading] ", fg="green") + style(str(config_file), fg="blue", bold=True) ) # ConfigReader allows us to open a yaml or json file as a dict - raw_config = config_reader.ConfigReader._from_file(config_file) + raw_config = config_reader.ConfigReader._from_file(config_file) or {} + # shapes configurations relative to config / profile file location sconfig = config.expand(raw_config, cwd=os.path.dirname(config_file)) # Overwrite session name @@ -374,9 +380,8 @@ def load_workspace( if builder.session_exists(session_name) and not append: if not detached and ( answer_yes - or click.confirm( - "%s is already running. Attach?" - % click.style(session_name, fg="green"), + or prompt_yes_no( + "%s is already running. Attach?" % style(session_name, fg="green"), default=True, ) ): @@ -407,7 +412,8 @@ def load_workspace( "Or (a)ppend windows in the current active session?\n[y/n/a]" ) options = ["y", "n", "a"] - choice = click.prompt(msg, value_proc=_validate_choices(options)) + choice = prompt_choices(msg, choices=options) + # value_proc=_validate_choices(options)) if choice == "y": _load_attached(builder, detached) @@ -424,9 +430,10 @@ def load_workspace( tmuxp_echo(traceback.format_exc(), err=True) tmuxp_echo(e, err=True) - choice = click.prompt( + choice = prompt_choices( "Error loading workspace. (k)ill, (a)ttach, (d)etach?", - value_proc=_validate_choices(["k", "a", "d"]), + choices=["k", "a", "d"], + # value_proc=_validate_choices(["k", "a", "d"]), default="k", ) @@ -443,7 +450,7 @@ def load_workspace( def config_file_completion(ctx, params, incomplete): config_dir = pathlib.Path(get_config_dir()) - choices: List[pathlib.Path] = [] + choices: t.List[pathlib.Path] = [] # CWD Paths choices += sorted( @@ -463,54 +470,99 @@ def config_file_completion(ctx, params, incomplete): return sorted(str(c) for c in choices if str(c).startswith(incomplete)) -@click.command(name="load", short_help="Load tmuxp workspaces.") -@click.pass_context -@click.argument( - "config", - type=ConfigPath(exists=True), - nargs=-1, - shell_complete=config_file_completion, -) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option("-f", "tmux_config_file", help="pass-through for tmux -f") -@click.option("-s", "new_session_name", help="start new session with new session name") -@click.option("--yes", "-y", "answer_yes", help="yes", is_flag=True) -@click.option( - "-d", "detached", help="Load the session without attaching it", is_flag=True -) -@click.option( - "-a", - "append", - help="Load configuration, appending windows to the current session", - is_flag=True, -) -@click.option( - "colors", - "-2", - flag_value=256, - default=True, - help="Force tmux to assume the terminal supports 256 colours.", -) -@click.option( - "colors", - "-8", - flag_value=88, - help="Like -2, but indicates that the terminal supports 88 colours.", -) -@click.option("--log-file", help="File to log errors/output to") +def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + parser.add_argument( + "config_file", + metavar="config-file", + help="filepath to session or filename of session if in tmuxp config directory", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket_name", + action="store", + help="passthru to tmux(1) -L", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket_path", + action="store", + help="passthru to tmux(1) -S", + ) + + parser.add_argument( + "-f", + dest="tmux_config_file", + metavar="tmux_config_file", + help="passthru to tmux(1) -f", + ) + parser.add_argument( + "-s", + dest="new_session_name", + metavar="new_session_name", + help="start new session with new session name", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="always answer yes", + ) + parser.add_argument( + "-d", + dest="detached", + action="store_true", + help="load the session without attaching it", + ) + parser.add_argument( + "-a", + "--append", + dest="append", + action="store_true", + help="load configuration, appending windows to the current session", + ) + colorsgroup = parser.add_mutually_exclusive_group() + + colorsgroup.add_argument( + "-2", + dest="colors", + action="store_const", + const=256, + help="force tmux to assume the terminal supports 256 colours.", + ) + + colorsgroup.add_argument( + "-8", + dest="colors", + action="store_const", + const=88, + help="like -2, but indicates that the terminal supports 88 colours.", + ) + + parser.set_defaults(colors=None) + parser.add_argument( + "--log-file", + metavar="file_path", + action="store", + help="file to log errors/output to", + ) + return parser + + def command_load( - ctx, - config, - socket_name, - socket_path, - tmux_config_file, - new_session_name, - answer_yes, - detached, - append, - colors, - log_file, + config_file: t.Union[str, pathlib.Path], + socket_name: t.Optional[str] = None, + socket_path: t.Optional[str] = None, + tmux_config_file: t.Optional[str] = None, + new_session_name: t.Optional[str] = None, + answer_yes: t.Optional[bool] = None, + detached: t.Optional[bool] = None, + append: t.Optional[str] = None, + colors: t.Optional[str] = None, + log_file: t.Optional[str] = None, + parser: t.Optional[argparse.ArgumentParser] = None, ): """Load a tmux workspace from each CONFIG. @@ -536,6 +588,9 @@ def command_load( """ util.oh_my_zsh_auto_title() + if isinstance(config_file, str): + config_file = pathlib.Path(config_file) + if log_file: logfile_handler = logging.FileHandler(log_file) logfile_handler.setFormatter(log.LogFormatter()) @@ -554,16 +609,18 @@ def command_load( "append": append, } - if not config: - tmuxp_echo("Enter at least one CONFIG") - tmuxp_echo(ctx.get_help(), color=ctx.color) - ctx.exit() + if config_file is None: + tmuxp_echo("Enter at least one config") + if parser is not None: + parser.print_help() + sys.exit() - if isinstance(config, str): - load_workspace(config, **tmux_options) + config_file = scan_config(config_file, config_dir=get_config_dir()) - elif isinstance(config, tuple): - config = list(config) + if isinstance(config_file, str): + load_workspace(config_file, **tmux_options) + elif isinstance(config_file, tuple): + config = list(config_file) # Load each configuration but the last to the background for cfg in config[:-1]: opt = tmux_options.copy() @@ -571,4 +628,8 @@ def command_load( load_workspace(cfg, **opt) # todo: obey the -d in the cli args only if user specifies - load_workspace(config[-1], **tmux_options) + load_workspace(config_file[-1], **tmux_options) + else: + raise NotImplementedError( + f"config {type(config_file)} with {config_file} not valid" + ) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index 0164ea9a929..a342a2f78d2 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -1,16 +1,20 @@ +import argparse import os - -import click +import typing as t from .constants import VALID_CONFIG_DIR_FILE_EXTENSIONS from .utils import get_config_dir -@click.command( - name="ls", - short_help="List configured sessions in :meth:`tmuxp.cli.utils.get_config_dir`.", -) -def command_ls(): +def create_ls_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + return parser + + +def command_ls( + parser: t.Optional[argparse.ArgumentParser] = None, +): tmuxp_dir = get_config_dir() if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): for f in sorted(os.listdir(tmuxp_dir)): diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 6d52a734ed0..3a1c2c96bdb 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -1,59 +1,121 @@ +import argparse import os +import typing as t -import click - -from libtmux import Server +from libtmux.server import Server from .. import util from .._compat import PY3, PYMINOR -@click.command(name="shell") -@click.argument("session_name", nargs=1, required=False) -@click.argument("window_name", nargs=1, required=False) -@click.option("-S", "socket_path", help="pass-through for tmux -S") -@click.option("-L", "socket_name", help="pass-through for tmux -L") -@click.option( - "-c", - "command", - help="Instead of opening shell, execute python code in libtmux and exit", -) -@click.option( - "--best", - "shell", - flag_value="best", - help="Use best shell available in site packages", - default=True, -) -@click.option("--pdb", "shell", flag_value="pdb", help="Use plain pdb") -@click.option("--code", "shell", flag_value="code", help="Use stdlib's code.interact()") -@click.option( - "--ptipython", "shell", flag_value="ptipython", help="Use ptpython + ipython" -) -@click.option("--ptpython", "shell", flag_value="ptpython", help="Use ptpython") -@click.option("--ipython", "shell", flag_value="ipython", help="Use ipython") -@click.option("--bpython", "shell", flag_value="bpython", help="Use bpython") -@click.option( - "--use-pythonrc/--no-startup", - "use_pythonrc", - help="Load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", - default=False, -) -@click.option( - "--use-vi-mode/--no-vi-mode", - "use_vi_mode", - help="Use vi-mode in ptpython/ptipython", - default=False, -) +def create_shell_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + parser.add_argument("session_name", metavar="session-name", nargs="?") + parser.add_argument("window_name", metavar="window-name", nargs="?") + parser.add_argument( + "-S", dest="socket_path", metavar="socket-path", help="pass-through for tmux -S" + ) + parser.add_argument( + "-L", dest="socket_name", metavar="socket-name", help="pass-through for tmux -L" + ) + parser.add_argument( + "-c", + dest="command", + help="instead of opening shell, execute python code in libtmux and exit", + ) + + shells = parser.add_mutually_exclusive_group() + shells.add_argument( + "--best", + dest="shell", + const="best", + action="store_const", + help="use best shell available in site packages", + default="best", + ) + shells.add_argument( + "--pdb", + dest="shell", + const="pdb", + action="store_const", + help="use plain pdb", + ) + shells.add_argument( + "--code", + dest="shell", + const="code", + action="store_const", + help="use stdlib's code.interact()", + ) + shells.add_argument( + "--ptipython", + dest="shell", + const="ptipython", + action="store_const", + help="use ptpython + ipython", + ) + shells.add_argument( + "--ptpython", + dest="shell", + const="ptpython", + action="store_const", + help="use ptpython", + ) + shells.add_argument( + "--ipython", + dest="shell", + const="ipython", + action="store_const", + help="use ipython", + ) + shells.add_argument( + "--bpython", + dest="shell", + const="bpython", + action="store_const", + help="use bpython", + ) + + parser.add_argument( + "--use-pythonrc", + dest="use_pythonrc", + action="store_true", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--no-startup", + dest="use_pythonrc", + action="store_false", + help="load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code", + default=False, + ) + parser.add_argument( + "--use-vi-mode", + dest="use_vi_mode", + action="store_true", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + parser.add_argument( + "--no-vi-mode", + dest="use_vi_mode", + action="store_false", + help="use vi-mode in ptpython/ptipython", + default=False, + ) + return parser + + def command_shell( session_name, window_name, socket_name, socket_path, - command, - shell, - use_pythonrc, - use_vi_mode, + command: t.Optional[str] = None, + shell: t.Optional[str] = None, + use_pythonrc: bool = False, + use_vi_mode: bool = False, + parser: t.Optional[argparse.ArgumentParser] = None, ): """Launch python shell for tmux server, session, window and pane. diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index ba6cceccdec..c750adfe36d 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -1,8 +1,9 @@ import logging import os +import re +import typing as t -import click -from click.exceptions import FileError +from colorama import Fore from .. import config, log from .constants import VALID_CONFIG_DIR_FILE_EXTENSIONS @@ -10,16 +11,23 @@ logger = logging.getLogger(__name__) -def tmuxp_echo(message=None, log_level="INFO", style_log=False, **click_kwargs): +def tmuxp_echo( + message: t.Optional[str] = None, + log_level="INFO", + style_log: bool = False, +) -> None: """ Combines logging.log and click.echo """ + if message is None: + return + if style_log: logger.log(log.LOG_LEVELS[log_level], message) else: - logger.log(log.LOG_LEVELS[log_level], click.unstyle(message)) + logger.log(log.LOG_LEVELS[log_level], unstyle(message)) - click.echo(message, **click_kwargs) + print(message) def get_config_dir(): @@ -75,15 +83,13 @@ def _validate_choices(options): def func(value): if value not in options: - raise click.BadParameter( - "Possible choices are: {}.".format(", ".join(options)) - ) + raise ValueError("Possible choices are: {}.".format(", ".join(options))) return value return func -class ConfigPath(click.Path): +class ConfigPath: def __init__(self, config_dir=None, *args, **kwargs): super().__init__(*args, **kwargs) self.config_dir = config_dir @@ -155,10 +161,6 @@ def scan_config(config, config_dir=None): - a file name, myconfig.yaml - relative path, ../config.yaml or ../project - a period, . - - Raises - ------ - :class:`click.exceptions.FileError` """ if not config_dir: config_dir = get_config_dir() @@ -210,18 +212,14 @@ def scan_config(config, config_dir=None): if len(candidates) > 1: tmuxp_echo( - click.style( - "Multiple .tmuxp.{yml,yaml,json} configs in %s" - % dirname(config), - fg="red", - ) + Fore.RED + + "Multiple .tmuxp.{yml,yaml,json} configs in %s" % dirname(config) + + Fore.RESET ) tmuxp_echo( - click.wrap_text( - "This is undefined behavior, use only one. " - "Use file names e.g. myproject.json, coolproject.yaml. " - "You can load them by filename." - ) + "This is undefined behavior, use only one. " + "Use file names e.g. myproject.json, coolproject.yaml. " + "You can load them by filename." ) elif not len(candidates): file_error = "No tmuxp files found in directory" @@ -231,7 +229,7 @@ def scan_config(config, config_dir=None): file_error = "file not found" if file_error: - raise FileError(file_error, config) + raise FileNotFoundError(file_error, config) return config @@ -257,3 +255,223 @@ def is_pure_name(path): and path != "." and path != "" ) + + +def prompt( + name: str, + default: t.Any = None, + value_proc: t.Optional[t.Callable[[str], str]] = None, +) -> t.Any: + """Return user input from command line. + :meth:`~prompt`, :meth:`~prompt_bool` and :meth:`prompt_choices` are from + `flask-script`_. See the `flask-script license`_. + .. _flask-script: https://github.com/techniq/flask-script + .. _flask-script license: + https://github.com/techniq/flask-script/blob/master/LICENSE + :param name: prompt text + :param default: default value if no input provided. + :rtype: string + """ + + _prompt = name + (default and " [%s]" % default or "") + _prompt += name.endswith("?") and " " or ": " + while True: + rv = input(_prompt) or default + try: + if value_proc is not None and callable(value_proc): + assert isinstance(rv, str) + value_proc(rv) + except ValueError as e: + return prompt(str(e), default=default, value_proc=value_proc) + + if rv: + return rv + if default is not None: + return default + + +def prompt_bool( + name: str, + default: bool = False, + yes_choices: t.Optional[t.Sequence[t.Any]] = None, + no_choices: t.Optional[t.Sequence[t.Any]] = None, +) -> bool: + """Return user input from command line and converts to boolean value. + :param name: prompt text + :param default: default value if no input provided. + :param yes_choices: default 'y', 'yes', '1', 'on', 'true', 't' + :param no_choices: default 'n', 'no', '0', 'off', 'false', 'f' + :rtype: bool + """ + + yes_choices = yes_choices or ("y", "yes", "1", "on", "true", "t") + no_choices = no_choices or ("n", "no", "0", "off", "false", "f") + + if default is None: + prompt_choice = "y/n" + elif default is True: + prompt_choice = "Y/n" + else: + prompt_choice = "y/N" + + _prompt = name + " [%s]" % prompt_choice + _prompt += name.endswith("?") and " " or ": " + + while True: + rv = input(_prompt) + if not rv: + return default + if rv.lower() in yes_choices: + return True + elif rv.lower() in no_choices: + return False + + +def prompt_yes_no(name: str, default: bool = True) -> bool: + """:meth:`prompt_bool()` returning yes by default.""" + return prompt_bool(name, default=default) + + +def prompt_choices( + name: str, + choices: t.List[str], + default: t.Optional[str] = None, + no_choice: t.Sequence[str] = ("none",), +): + """Return user input from command line from set of provided choices. + :param name: prompt text + :param choices: list or tuple of available choices. Choices may be + single strings or (key, value) tuples. + :param default: default value if no input provided. + :param no_choice: acceptable list of strings for "null choice" + :rtype: str + """ + + _choices = [] + options = [] + + for choice in choices: + if isinstance(choice, str): + options.append(choice) + else: + options.append("%s [%s]" % (choice, choice[0])) + choice = choice[0] + _choices.append(choice) + + while True: + rv = prompt(name + " - (%s)" % ", ".join(options), default) + if not rv: + return default + rv = rv.lower() + if rv in no_choice: + return None + if rv in _choices: + return rv + + +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def strip_ansi(value: str) -> str: + return _ansi_re.sub("", value) + + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +def _interpret_color( + color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0 +) -> str: + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +def style( + text: t.Any, + fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bold: t.Optional[bool] = None, + dim: t.Optional[bool] = None, + underline: t.Optional[bool] = None, + overline: t.Optional[bool] = None, + italic: t.Optional[bool] = None, + blink: t.Optional[bool] = None, + reverse: t.Optional[bool] = None, + strikethrough: t.Optional[bool] = None, + reset: bool = True, +) -> str: + """Credit: click""" + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except KeyError: + raise TypeError(f"Unknown color {fg!r}") from None + + if bg: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except KeyError: + raise TypeError(f"Unknown color {bg!r}") from None + + if bold is not None: + bits.append(f"\033[{1 if bold else 22}m") + if dim is not None: + bits.append(f"\033[{2 if dim else 22}m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + """Removes ANSI styling information from a string. Usually it's not + necessary to use this function as tmuxp_echo function will + automatically remove styling if necessary. + + credit: click. + + :param text: the text to remove style information from. + """ + return strip_ansi(text) diff --git a/tests/test_cli.py b/tests/test_cli.py index c92e2008c36..a5dfd18dbce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ """Test for tmuxp command line interface.""" +import argparse +import io import json import os import pathlib @@ -6,15 +8,12 @@ import pytest -import click -from click.testing import CliRunner from pytest_mock import MockerFixture import libtmux from libtmux.common import has_lt_version from libtmux.exc import LibTmuxException from tmuxp import cli, config, exc -from tmuxp.cli.debug_info import command_debug_info from tmuxp.cli.import_config import get_teamocil_dir, get_tmuxinator_dir from tmuxp.cli.load import ( _load_append_windows_to_current_session, @@ -23,14 +22,13 @@ load_plugins, load_workspace, ) -from tmuxp.cli.ls import command_ls from tmuxp.cli.utils import ( - ConfigPath, _validate_choices, get_abs_path, get_config_dir, is_pure_name, scan_config, + tmuxp_echo, ) from tmuxp.config_reader import ConfigReader from tmuxp.workspacebuilder import WorkspaceBuilder @@ -39,6 +37,8 @@ from .fixtures import utils as test_utils if t.TYPE_CHECKING: + import _pytest.capture + from libtmux.server import Server @@ -272,14 +272,17 @@ def test_resolve_dot( def test_scan_config_arg( - homedir, configdir, projectdir, monkeypatch: pytest.MonkeyPatch + homedir, + configdir, + projectdir, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, ): - runner = CliRunner() + parser = argparse.ArgumentParser() + parser.add_argument("config_file", type=str) - @click.command() - @click.argument("config", type=ConfigPath(exists=True), nargs=-1) - def config_cmd(config): - click.echo(config) + def config_cmd(config_file: str) -> None: + tmuxp_echo(scan_config(config_file, config_dir=configdir)) monkeypatch.setenv("HOME", str(homedir)) tmuxp_config_path = projectdir / ".tmuxp.yaml" @@ -290,25 +293,31 @@ def config_cmd(config): project_config = projectdir / ".tmuxp.yaml" - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output + def check_cmd(config_arg) -> "_pytest.capture.CaptureResult": + args = parser.parse_args([config_arg]) + config_cmd(config_file=args.config_file) + return capsys.readouterr() monkeypatch.chdir(projectdir) expect = str(project_config) - assert expect in check_cmd(".") - assert expect in check_cmd("./") - assert expect in check_cmd("") - assert expect in check_cmd("../project") - assert expect in check_cmd("../project/") - assert expect in check_cmd(".tmuxp.yaml") - assert str(user_config) in check_cmd("../../.tmuxp/%s.yaml" % user_config_name) - assert user_config.stem in check_cmd("myconfig") - assert str(user_config) in check_cmd("~/.tmuxp/myconfig.yaml") - - assert "file not found" in check_cmd(".tmuxp.json") - assert "file not found" in check_cmd(".tmuxp.ini") - assert "No tmuxp files found" in check_cmd("../") - assert "config not found in config dir" in check_cmd("moo") + assert expect in check_cmd(".").out + assert expect in check_cmd("./").out + assert expect in check_cmd("").out + assert expect in check_cmd("../project").out + assert expect in check_cmd("../project/").out + assert expect in check_cmd(".tmuxp.yaml").out + assert str(user_config) in check_cmd("../../.tmuxp/%s.yaml" % user_config_name).out + assert user_config.stem in check_cmd("myconfig").out + assert str(user_config) in check_cmd("~/.tmuxp/myconfig.yaml").out + + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.json").err + with pytest.raises(FileNotFoundError, match="file not found"): + assert "file not found" in check_cmd(".tmuxp.ini").err + with pytest.raises(FileNotFoundError, match="No tmuxp files found"): + assert "No tmuxp files found" in check_cmd("../").err + with pytest.raises(FileNotFoundError, match="config not found in config dir"): + assert "config not found in config dir" in check_cmd("moo").err def test_load_workspace(server, monkeypatch): @@ -416,62 +425,88 @@ def test_load_symlinked_workspace(server, tmp_path, monkeypatch): def test_regression_00132_session_name_with_dots( - tmp_path: pathlib.Path, server, session + tmp_path: pathlib.Path, + server, + session, + capsys: pytest.CaptureFixture, ): yaml_config = FIXTURE_PATH / "workspacebuilder" / "regression_00132_dots.yaml" cli_args = [str(yaml_config)] - inputs: t.List[str] = [] - runner = CliRunner() - result = runner.invoke( - cli.command_load, cli_args, input="".join(inputs), standalone_mode=False - ) - assert result.exception - assert isinstance(result.exception, libtmux.exc.BadSessionName) + with pytest.raises(libtmux.exc.BadSessionName): + cli.cli(["load", *cli_args]) -@pytest.mark.parametrize("cli_args", [(["load", "."]), (["load", ".tmuxp.yaml"])]) -def test_load_zsh_autotitle_warning(cli_args, tmp_path, monkeypatch): +@pytest.mark.parametrize( + "cli_args", [["load", ".", "-d"], ["load", ".tmuxp.yaml", "-d"]] +) +def test_load_zsh_autotitle_warning( + cli_args: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + server: "Server", +) -> None: # create dummy tmuxp yaml so we don't get yelled at yaml_config = tmp_path / ".tmuxp.yaml" - yaml_config.touch() + yaml_config.write_text( + """ + session_name: test + windows: + - window_name: test + panes: + - + """, + encoding="utf-8", + ) oh_my_zsh_path = tmp_path / ".oh-my-zsh" oh_my_zsh_path.mkdir() monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) monkeypatch.setenv("SHELL", "zsh") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" in result.output + + # Use tmux server (socket name) used in the test + assert server.socket_name is not None + cli_args = cli_args + ["-L", server.socket_name] + + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out monkeypatch.setenv("DISABLE_AUTO_TITLE", "false") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" in result.output + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" in result.out monkeypatch.setenv("DISABLE_AUTO_TITLE", "true") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" not in result.output + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) monkeypatch.setenv("SHELL", "sh") - result = runner.invoke(cli.cli, cli_args) - assert "Please set" not in result.output + cli.cli(cli_args) + result = capsys.readouterr() + assert "Please set" not in result.out @pytest.mark.parametrize( "cli_args", [ - (["load", ".", "--log-file", "log.txt"]), + (["load", ".", "--log-file", "log.txt", "-d"]), ], ) -def test_load_log_file(cli_args, tmp_path, monkeypatch): +def test_load_log_file( + cli_args, tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +): # create dummy tmuxp yaml that breaks to prevent actually loading tmux tmuxp_config_path = tmp_path / ".tmuxp.yaml" tmuxp_config_path.write_text( """ session_name: hello + - """, encoding="utf-8", ) @@ -480,15 +515,15 @@ def test_load_log_file(cli_args, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() - # If autoconfirm (-y) no need to prompt y - input_args = "y\ny\n" if "-y" not in cli_args else "" - - result = runner.invoke(cli.cli, cli_args, input=input_args) + try: + cli.cli(cli_args) + except Exception: + pass + result = capsys.readouterr() log_file_path = tmp_path / "log.txt" assert "Loading" in log_file_path.open().read() - assert result is not None + assert result.out is not None @pytest.mark.parametrize("cli_cmd", ["shell", ("shell", "--pdb")]) @@ -566,10 +601,11 @@ def test_shell( inputs, expected_output, env, - tmp_path, - monkeypatch, server, session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, ): monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" @@ -592,12 +628,10 @@ def test_shell( monkeypatch.setenv(k, v.format(**template_ctx)) monkeypatch.chdir(tmp_path) - runner = CliRunner() - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) - assert expected_output.format(**template_ctx) in result.output + cli.cli(cli_args) + result = capsys.readouterr() + assert expected_output.format(**template_ctx) in result.out @pytest.mark.parametrize( @@ -655,11 +689,12 @@ def test_shell_target_missing( template_ctx, exception, message, - tmp_path, - monkeypatch, socket_name, server, session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, ): monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" @@ -681,18 +716,14 @@ def test_shell_target_missing( monkeypatch.setenv(k, v.format(**template_ctx)) monkeypatch.chdir(tmp_path) - runner = CliRunner() if exception is not None: with pytest.raises(exception, match=message.format(**template_ctx)): - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) + cli.cli(cli_args) else: - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=False - ) - assert message.format(**template_ctx) in result.output + cli.cli(cli_args) + result = capsys.readouterr() + assert message.format(**template_ctx) in result.out @pytest.mark.parametrize( @@ -728,16 +759,17 @@ def test_shell_target_missing( ), ], ) -def test_shell_plus( +def test_shell_interactive( cli_cmd, cli_args, inputs, env, message, - tmp_path, - monkeypatch, server, session, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, ): monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" @@ -760,12 +792,13 @@ def test_shell_plus( monkeypatch.setenv(k, v.format(**template_ctx)) monkeypatch.chdir(tmp_path) - runner = CliRunner() - - result = runner.invoke( - cli.cli, cli_args, input="".join(inputs), catch_exceptions=True - ) - assert message.format(**template_ctx) in result.output + monkeypatch.setattr("sys.stdin", io.StringIO("exit()\r")) + try: + cli.cli(cli_args) + except SystemExit: + pass + result = capsys.readouterr() + assert message.format(**template_ctx) in result.err @pytest.mark.parametrize( @@ -778,7 +811,11 @@ def test_shell_plus( (["convert", ".tmuxp.yml", "-y"]), ], ) -def test_convert(cli_args, tmp_path, monkeypatch): +def test_convert( + cli_args, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +): # create dummy tmuxp yaml so we don't get yelled at filename = cli_args[1] if filename == ".": @@ -792,12 +829,15 @@ def test_convert(cli_args, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() # If autoconfirm (-y) no need to prompt y input_args = "y\ny\n" if "-y" not in cli_args else "" - runner.invoke(cli.cli, cli_args, input=input_args) + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + try: + cli.cli(cli_args) + except SystemExit: + pass tmuxp_json = tmp_path / ".tmuxp.json" assert tmuxp_json.exists() assert tmuxp_json.open().read() == json.dumps({"session_name": "hello"}, indent=2) @@ -811,7 +851,11 @@ def test_convert(cli_args, tmp_path, monkeypatch): (["convert", ".tmuxp.json", "-y"]), ], ) -def test_convert_json(cli_args, tmp_path, monkeypatch): +def test_convert_json( + cli_args, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +): # create dummy tmuxp yaml so we don't get yelled at json_config = tmp_path / ".tmuxp.json" json_config.write_text('{"session_name": "hello"}', encoding="utf-8") @@ -820,24 +864,32 @@ def test_convert_json(cli_args, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() # If autoconfirm (-y) no need to prompt y input_args = "y\ny\n" if "-y" not in cli_args else "" - runner.invoke(cli.cli, cli_args, input=input_args) + monkeypatch.setattr("sys.stdin", io.StringIO(input_args)) + try: + cli.cli(cli_args) + except SystemExit: + pass + tmuxp_yaml = tmp_path / ".tmuxp.yaml" assert tmuxp_yaml.exists() assert tmuxp_yaml.open().read() == "session_name: hello\n" @pytest.mark.parametrize("cli_args", [(["import"])]) -def test_import(cli_args, monkeypatch): - runner = CliRunner() - - result = runner.invoke(cli.cli, cli_args) - assert "tmuxinator" in result.output - assert "teamocil" in result.output +def test_import( + cli_args, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +): + cli.cli(cli_args) + result = capsys.readouterr() + assert "tmuxinator" in result.out + assert "teamocil" in result.out @pytest.mark.parametrize( @@ -847,11 +899,19 @@ def test_import(cli_args, monkeypatch): (["-h"]), ], ) -def test_help(cli_args, monkeypatch): - runner = CliRunner() +def test_help( + cli_args, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +): + try: + cli.cli(cli_args) + except SystemExit: + pass + result = capsys.readouterr() - result = runner.invoke(cli.cli, cli_args) - assert "Usage: cli [OPTIONS] COMMAND [ARGS]..." in result.output + assert "usage: tmuxp [-h] [--version] [--log-level LOG_LEVEL]" in result.out @pytest.mark.parametrize( @@ -871,7 +931,9 @@ def test_help(cli_args, monkeypatch): ), ], ) -def test_import_teamocil(cli_args, inputs, tmp_path, monkeypatch): +def test_import_teamocil( + cli_args, inputs, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): teamocil_config = test_utils.read_config_file("config_teamocil/test4.yaml") teamocil_path = tmp_path / ".teamocil" @@ -886,8 +948,12 @@ def test_import_teamocil(cli_args, inputs, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() - runner.invoke(cli.cli, cli_args, input="".join(inputs)) + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + try: + cli.cli(cli_args) + except SystemExit: + pass new_config_yaml = tmp_path / "la.yaml" assert new_config_yaml.exists() @@ -925,9 +991,13 @@ def test_import_tmuxinator(cli_args, inputs, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.chdir(tmp_path) - runner = CliRunner() - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + try: + cli.cli(cli_args) + except SystemExit: + pass + new_config_yaml = tmp_path / "la.yaml" assert new_config_yaml.exists() @@ -947,7 +1017,9 @@ def test_import_tmuxinator(cli_args, inputs, tmp_path, monkeypatch): (["freeze"], ["y\n", "./exists.yaml\n", "./la.yaml\n", "y\n"]), # Exists ], ) -def test_freeze(server, cli_args, inputs, tmp_path, monkeypatch): +def test_freeze( + server, cli_args, inputs, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) exists_yaml = tmp_path / "exists.yaml" exists_yaml.touch() @@ -963,11 +1035,14 @@ def test_freeze(server, cli_args, inputs, tmp_path, monkeypatch): monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) monkeypatch.chdir(tmp_path) - runner = CliRunner() # Use tmux server (socket name) used in the test cli_args = cli_args + ["-L", server.socket_name] - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + try: + cli.cli(cli_args) + except SystemExit: + pass yaml_config_path = tmp_path / "la.yaml" assert yaml_config_path.exists() @@ -983,11 +1058,11 @@ def test_freeze(server, cli_args, inputs, tmp_path, monkeypatch): [ ( # Overwrite ["freeze", "mysession", "--force"], - ["\n", "y\n", "./exists.yaml\n", "y\n"], + ["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], ), ( # Imply current session if not entered ["freeze", "--force"], - ["\n", "y\n", "./exists.yaml\n", "y\n"], + ["\n", "\n", "y\n", "./exists.yaml\n", "y\n"], ), ], ) @@ -999,11 +1074,14 @@ def test_freeze_overwrite(server, cli_args, inputs, tmp_path, monkeypatch): server.new_session(session_name="mysession") monkeypatch.chdir(tmp_path) - runner = CliRunner() # Use tmux server (socket name) used in the test cli_args = cli_args + ["-L", server.socket_name] - out = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - print(out.output) + + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + try: + cli.cli(cli_args) + except SystemExit: + pass yaml_config_path = tmp_path / "exists.yaml" assert yaml_config_path.exists() @@ -1040,12 +1118,14 @@ def test_validate_choices(): assert validate("choice1") assert validate("choice2") - with pytest.raises(click.BadParameter): + with pytest.raises(ValueError): assert validate("choice3") def test_pass_config_dir_ClickPath( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, ): configdir = tmp_path / "myconfigdir" @@ -1056,31 +1136,33 @@ def test_pass_config_dir_ClickPath( expect = str(user_config) - runner = CliRunner() + parser = argparse.ArgumentParser() + parser.add_argument("config_file", type=str) - @click.command() - @click.argument( - "config", - type=ConfigPath(exists=True, config_dir=(str(configdir))), - nargs=-1, - ) - def config_cmd(config): - click.echo(config) + def config_cmd(config_file: str) -> None: + tmuxp_echo(scan_config(config_file, config_dir=configdir)) - def check_cmd(config_arg): - return runner.invoke(config_cmd, [config_arg]).output + def check_cmd(config_arg) -> "_pytest.capture.CaptureResult": + args = parser.parse_args([config_arg]) + config_cmd(config_file=args.config_file) + return capsys.readouterr() monkeypatch.chdir(configdir) - assert expect in check_cmd("myconfig") - assert expect in check_cmd("myconfig.yaml") - assert expect in check_cmd("./myconfig.yaml") - assert str(user_config) in check_cmd(str(configdir / "myconfig.yaml")) + assert expect in check_cmd("myconfig").out + assert expect in check_cmd("myconfig.yaml").out + assert expect in check_cmd("./myconfig.yaml").out + assert str(user_config) in check_cmd(str(configdir / "myconfig.yaml")).out - assert "file not found" in check_cmd(".tmuxp.json") + with pytest.raises(FileNotFoundError): + assert "FileNotFoundError" in check_cmd(".tmuxp.json").out -def test_ls_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): +def test_ls_cli( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture, +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config")) @@ -1106,8 +1188,12 @@ def test_ls_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): else: location.touch() - runner = CliRunner() - cli_output = runner.invoke(command_ls).output + try: + cli.cli(["ls"]) + except SystemExit: + pass + cli_output = capsys.readouterr().out + assert cli_output == "\n".join(stems) + "\n" @@ -1141,12 +1227,15 @@ def test_load_plugins(monkeypatch_plugin_test_packages): ], ) def test_load_plugins_version_fail_skip( - monkeypatch_plugin_test_packages, cli_args, inputs -): - runner = CliRunner() + monkeypatch_plugin_test_packages, cli_args, inputs, capsys: pytest.CaptureFixture +) -> None: + try: + cli.cli(cli_args) + except SystemExit: + pass + result = capsys.readouterr() - results = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - assert "[Loading]" in results.output + assert "[Loading]" in result.out @pytest.mark.parametrize( @@ -1159,22 +1248,36 @@ def test_load_plugins_version_fail_skip( ], ) def test_load_plugins_version_fail_no_skip( - monkeypatch_plugin_test_packages, cli_args, inputs -): - runner = CliRunner() + monkeypatch_plugin_test_packages, + cli_args, + inputs, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) + + try: + cli.cli(cli_args) + except SystemExit: + pass + result = capsys.readouterr() - results = runner.invoke(cli.cli, cli_args, input="".join(inputs)) - assert "[Not Skipping]" in results.output + assert "[Not Skipping]" in result.out @pytest.mark.parametrize( "cli_args", [(["load", "tests/fixtures/workspacebuilder/plugin_missing_fail.yaml"])] ) -def test_load_plugins_plugin_missing(monkeypatch_plugin_test_packages, cli_args): - runner = CliRunner() +def test_load_plugins_plugin_missing( + monkeypatch_plugin_test_packages, cli_args, capsys: pytest.CaptureFixture +) -> None: + try: + cli.cli(cli_args) + except SystemExit: + pass + result = capsys.readouterr() - results = runner.invoke(cli.cli, cli_args) - assert "[Plugin Error]" in results.output + assert "[Plugin Error]" in result.out def test_plugin_system_before_script( @@ -1313,11 +1416,13 @@ def test_load_append_windows_to_current_session(server, monkeypatch): assert len(server._list_windows()) == 6 -def test_debug_info_cli(monkeypatch, tmp_path: pathlib.Path): +def test_debug_info_cli( + monkeypatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture +) -> None: monkeypatch.setenv("SHELL", "/bin/bash") - runner = CliRunner() - cli_output = runner.invoke(command_debug_info).output + cli.cli(["debug-info"]) + cli_output = capsys.readouterr().out assert "environment" in cli_output assert "python version" in cli_output assert "system PATH" in cli_output From be127c6af98be487ea9c7dce48e5b6bb73025eb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 9 Oct 2022 11:50:49 -0500 Subject: [PATCH 8/9] refactor: Basic typings and namespaces --- conftest.py | 6 +- src/tmuxp/cli/__init__.py | 64 ++++---- src/tmuxp/cli/convert.py | 2 +- src/tmuxp/cli/debug_info.py | 2 +- src/tmuxp/cli/edit.py | 3 +- src/tmuxp/cli/freeze.py | 83 +++++++---- src/tmuxp/cli/import_config.py | 16 +- src/tmuxp/cli/load.py | 109 +++++++------- src/tmuxp/cli/ls.py | 2 +- src/tmuxp/cli/shell.py | 52 ++++--- src/tmuxp/cli/utils.py | 48 +++--- tests/test_cli.py | 262 ++++++++++++++++++++------------- 12 files changed, 374 insertions(+), 275 deletions(-) diff --git a/conftest.py b/conftest.py index 0d4efe8cc89..8a628123218 100644 --- a/conftest.py +++ b/conftest.py @@ -25,7 +25,7 @@ @pytest.mark.skipif(USING_ZSH, reason="Using ZSH") @pytest.fixture(autouse=USING_ZSH, scope="session") -def zshrc(user_path: pathlib.Path): +def zshrc(user_path: pathlib.Path) -> pathlib.Path: """This quiets ZSH default message. Needs a startup file .zshenv, .zprofile, .zshrc, .zlogin. @@ -41,7 +41,7 @@ def home_path_default(monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path) @pytest.fixture(scope="function") -def monkeypatch_plugin_test_packages(monkeypatch): +def monkeypatch_plugin_test_packages(monkeypatch: pytest.MonkeyPatch) -> None: paths = [ "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bwb/", "tests/fixtures/pluginsystem/plugins/tmuxp_test_plugin_bs/", @@ -55,7 +55,7 @@ def monkeypatch_plugin_test_packages(monkeypatch): @pytest.fixture(scope="function") -def socket_name(request) -> str: +def socket_name(request: pytest.FixtureRequest) -> str: return "tmuxp_test%s" % next(namer) diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 9aa8f2d2d49..9dcf07ad68b 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -7,7 +7,9 @@ import argparse import logging import os +import pathlib import sys +import typing as t from libtmux.__about__ import __version__ as libtmux_version from libtmux.common import has_minimum_version @@ -19,19 +21,28 @@ from .convert import command_convert, create_convert_subparser from .debug_info import command_debug_info, create_debug_info_subparser from .edit import command_edit, create_edit_subparser -from .freeze import command_freeze, create_freeze_subparser +from .freeze import CLIFreezeNamespace, command_freeze, create_freeze_subparser from .import_config import ( command_import_teamocil, command_import_tmuxinator, create_import_subparser, ) -from .load import command_load, create_load_subparser +from .load import CLILoadNamespace, command_load, create_load_subparser from .ls import command_ls, create_ls_subparser -from .shell import command_shell, create_shell_subparser +from .shell import CLIShellNamespace, command_shell, create_shell_subparser from .utils import tmuxp_echo logger = logging.getLogger(__name__) +if t.TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias + + CLIVerbosity: TypeAlias = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + CLISubparserName: TypeAlias = Literal[ + "ls", "load", "convert", "edit", "import", "shell", "debug-info" + ] + CLIImportSubparserName: TypeAlias = Literal["teamocil", "tmuxinator"] + def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="tmuxp") @@ -83,7 +94,17 @@ def create_parser() -> argparse.ArgumentParser: return parser -def cli(args=None): +class CLINamespace(argparse.Namespace): + log_level: "CLIVerbosity" + subparser_name: "CLISubparserName" + import_subparser_name: t.Optional["CLIImportSubparserName"] + version: bool + + +ns = CLINamespace() + + +def cli(_args: t.Optional[t.List[str]] = None) -> None: """Manage tmux sessions. Pass the "--help" argument to any command to see detailed help. @@ -96,11 +117,11 @@ def cli(args=None): tmuxp_echo("tmux not found. tmuxp requires you install tmux first.") sys.exit() except exc.TmuxpException as e: - tmuxp_echo(e, err=True) + tmuxp_echo(str(e)) sys.exit() parser = create_parser() - args = parser.parse_args(args) + args = parser.parse_args(_args, namespace=ns) setup_logger(logger=logger, level=args.log_level.upper()) @@ -109,28 +130,12 @@ def cli(args=None): return elif args.subparser_name == "load": command_load( - config_file=args.config_file, - socket_name=args.socket_name, - socket_path=args.socket_path, - tmux_config_file=args.tmux_config_file, - new_session_name=args.new_session_name, - answer_yes=args.answer_yes, - detached=args.detached, - append=args.append, - colors=args.colors, - log_file=args.log_file, + args=CLILoadNamespace(**vars(args)), parser=parser, ) elif args.subparser_name == "shell": command_shell( - session_name=args.session_name, - window_name=args.window_name, - socket_name=args.socket_name, - socket_path=args.socket_path, - command=args.command, - shell=args.shell, - use_pythonrc=args.use_pythonrc, - use_vi_mode=args.use_vi_mode, + args=CLIShellNamespace(**vars(args)), parser=parser, ) elif args.subparser_name == "import": @@ -164,21 +169,14 @@ def cli(args=None): ) elif args.subparser_name == "freeze": command_freeze( - session_name=args.session_name, - socket_name=args.socket_name, - socket_path=args.socket_path, - config_format=args.config_format, - save_to=args.save_to, - answer_yes=args.answer_yes, - quiet=args.quiet, - force=args.force, + args=CLIFreezeNamespace(**vars(args)), parser=parser, ) elif args.subparser_name == "ls": command_ls(parser=parser) -def startup(config_dir): +def startup(config_dir: pathlib.Path) -> None: """ Initialize CLI. diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 12d2cfcd8cb..6ec65d85d17 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -31,7 +31,7 @@ def command_convert( config_file: t.Union[str, pathlib.Path], answer_yes: bool, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """Convert a tmuxp config between JSON and YAML.""" config_file = scan_config(config_file, config_dir=get_config_dir()) diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 4dba55303e1..a23fac1f88f 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -25,7 +25,7 @@ def create_debug_info_subparser( def command_debug_info( parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """ Print debug info to submit with Issues. """ diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index b2f2988b9b4..bc0864ce2c1 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -12,8 +12,9 @@ def create_edit_subparser( ) -> argparse.ArgumentParser: parser.add_argument( dest="config_file", + metavar="config-file", type=str, - help="checks current tmuxp and current directory for yaml files.", + help="checks current tmuxp and current directory for config files.", ) return parser diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 4646a156481..d8551bf5dda 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -10,12 +10,28 @@ from .. import config, util from ..workspacebuilder import freeze -from .utils import _validate_choices, get_config_dir, prompt, prompt_yes_no +from .utils import get_config_dir, prompt, prompt_choices, prompt_yes_no + +if t.TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias, TypeGuard + + CLIOutputFormatLiteral: TypeAlias = Literal["yaml", "json"] + + +class CLIFreezeNamespace(argparse.Namespace): + session_name: str + socket_name: t.Optional[str] + socket_path: t.Optional[str] + config_format: t.Optional["CLIOutputFormatLiteral"] + save_to: t.Optional[str] + answer_yes: t.Optional[bool] + quiet: t.Optional[bool] + force: t.Optional[bool] def session_completion(ctx, params, incomplete): - t = Server() - choices = [session.name for session in t.list_sessions()] + server = Server() + choices = [session.name for session in server.list_sessions()] return sorted(str(c) for c in choices if str(c).startswith(incomplete)) @@ -69,28 +85,20 @@ def create_freeze_subparser( def command_freeze( - session_name: t.Optional[str] = None, - socket_name: t.Optional[str] = None, - config_format: t.Optional[str] = None, - save_to: t.Optional[str] = None, - socket_path: t.Optional[str] = None, - answer_yes: t.Optional[bool] = None, - quiet: t.Optional[bool] = None, - force: t.Optional[bool] = None, + args: CLIFreezeNamespace, parser: t.Optional[argparse.ArgumentParser] = None, ) -> None: """Snapshot a session into a config. If SESSION_NAME is provided, snapshot that session. Otherwise, use the current session.""" - - t = Server(socket_name=socket_name, socket_path=socket_path) + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) try: - if session_name: - session = t.find_where({"session_name": session_name}) + if args.session_name: + session = server.find_where({"session_name": args.session_name}) else: - session = util.get_session(t) + session = util.get_session(server) if not session: raise TmuxpException("Session not found.") @@ -102,19 +110,19 @@ def command_freeze( newconfig = config.inline(sconf) configparser = ConfigReader(newconfig) - if not quiet: + if not args.quiet: print( "---------------------------------------------------------------" "\n" "Freeze does its best to snapshot live tmux sessions.\n" ) if not ( - answer_yes + args.answer_yes or prompt_yes_no( "The new config *WILL* require adjusting afterwards. Save config?" ) ): - if not quiet: + if not args.quiet: print( "tmuxp has examples in JSON and YAML format at " "\n" @@ -122,34 +130,47 @@ def command_freeze( ) sys.exit() - dest = save_to + dest = args.save_to while not dest: save_to = os.path.abspath( os.path.join( get_config_dir(), - "{}.{}".format(sconf.get("session_name"), config_format or "yaml"), + "{}.{}".format(sconf.get("session_name"), args.config_format or "yaml"), ) ) dest_prompt = prompt( "Save to: %s" % save_to, default=save_to, ) - if not force and os.path.exists(dest_prompt): + if not args.force and os.path.exists(dest_prompt): print("%s exists. Pick a new filename." % dest_prompt) continue dest = dest_prompt dest = os.path.abspath(os.path.relpath(os.path.expanduser(dest))) + config_format = args.config_format + + valid_config_formats: t.List["CLIOutputFormatLiteral"] = ["json", "yaml"] + + def is_valid_ext(stem: t.Optional[str]) -> "TypeGuard[CLIOutputFormatLiteral]": + return stem in valid_config_formats + + if not is_valid_ext(config_format): + + def extract_config_format(val: str) -> t.Optional["CLIOutputFormatLiteral"]: + suffix = pathlib.Path(val).suffix + if isinstance(suffix, str): + suffix = suffix.lower().lstrip(".") + if is_valid_ext(suffix): + return suffix + return None - if config_format is None or config_format == "": - valid_config_formats = ["json", "yaml"] - _, config_format = os.path.splitext(dest) - config_format = config_format[1:].lower() - if config_format not in valid_config_formats: - config_format = prompt( + config_format = extract_config_format(dest) + if not is_valid_ext(config_format): + config_format = prompt_choices( "Couldn't ascertain one of [%s] from file name. Convert to" % ", ".join(valid_config_formats), - value_proc=_validate_choices(["yaml", "json"]), + choices=valid_config_formats, default="yaml", ) @@ -160,7 +181,7 @@ def command_freeze( elif config_format == "json": newconfig = configparser.dump(format="json", indent=2) - if answer_yes or prompt_yes_no("Save to %s?" % dest): + if args.answer_yes or prompt_yes_no("Save to %s?" % dest): destdir = os.path.dirname(dest) if not os.path.isdir(destdir): os.makedirs(destdir) @@ -168,5 +189,5 @@ def command_freeze( buf.write(newconfig) buf.close() - if not quiet: + if not args.quiet: print("Saved to %s." % dest) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 9c2a9f8fe4c..9d1ad2fef10 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -17,7 +17,7 @@ ) -def get_tmuxinator_dir(): +def get_tmuxinator_dir() -> str: """ Return tmuxinator configuration directory. @@ -38,7 +38,7 @@ def get_tmuxinator_dir(): return os.path.expanduser("~/.tmuxinator/") -def get_teamocil_dir(): +def get_teamocil_dir() -> str: """ Return teamocil configuration directory. @@ -54,7 +54,7 @@ def get_teamocil_dir(): return os.path.expanduser("~/.teamocil/") -def _resolve_path_no_overwrite(config): +def _resolve_path_no_overwrite(config: str) -> str: path = get_abs_path(config) if os.path.exists(path): raise ValueError("%s exists. Pick a new filename." % path) @@ -115,10 +115,10 @@ def create_import_subparser( def import_config( - config_file, - importfunc, + config_file: str, + importfunc: t.Callable, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: existing_config = ConfigReader._from_file(pathlib.Path(config_file)) cfg_reader = ConfigReader(importfunc(existing_config)) @@ -168,7 +168,7 @@ def import_config( def command_import_tmuxinator( config_file: str, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """Convert a tmuxinator config from config_file to tmuxp format and import it into tmuxp.""" config_file = scan_config(config_file, config_dir=get_tmuxinator_dir()) @@ -196,7 +196,7 @@ def create_convert_subparser( def command_import_teamocil( config_file: str, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """Convert a teamocil config from config_file to tmuxp format and import it into tmuxp.""" config_file = scan_config(config_file, config_dir=get_teamocil_dir()) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 6d17cd00326..3db6c530033 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -15,9 +15,9 @@ from libtmux.common import has_gte_version from libtmux.server import Server -from tmuxp import config_reader +from libtmux.session import Session -from .. import config, exc, log, util +from .. import config, config_reader, exc, log, util from ..workspacebuilder import WorkspaceBuilder from .utils import ( get_config_dir, @@ -28,8 +28,25 @@ tmuxp_echo, ) +if t.TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias -def set_layout_hook(session, hook_name): + CLIColorsLiteral: TypeAlias = Literal[56, 88] + + +class CLILoadNamespace(argparse.Namespace): + config_file: str + socket_name: t.Optional[str] + socket_path: t.Optional[str] + tmux_config_file: t.Optional[str] + new_session_name: t.Optional[str] + answer_yes: t.Optional[bool] + append: t.Optional[bool] + colors: t.Optional["CLIColorsLiteral"] + log_file: t.Optional[str] + + +def set_layout_hook(session: Session, hook_name: str) -> None: """Set layout hooks to normalize layout. References: @@ -81,16 +98,16 @@ def set_layout_hook(session, hook_name): hook_cmd.append(f"selectw -t {attached_window.id}") # join the hook's commands with semicolons - hook_cmd = "{}".format("; ".join(hook_cmd)) + _hook_cmd = "{}".format("; ".join(hook_cmd)) # append the hook command - cmd.append(hook_cmd) + cmd.append(_hook_cmd) # create the hook session.cmd(*cmd) -def load_plugins(sconf): +def load_plugins(sconf: t.Any) -> t.List[t.Any]: """ Load and return plugins in config """ @@ -125,7 +142,7 @@ def load_plugins(sconf): return plugins -def _reattach(builder): +def _reattach(builder: WorkspaceBuilder): """ Reattach session (depending on env being inside tmux already or not) @@ -154,7 +171,7 @@ def _reattach(builder): builder.session.attach_session() -def _load_attached(builder, detached): +def _load_attached(builder: WorkspaceBuilder, detached: bool) -> None: """ Load config in new session @@ -187,7 +204,7 @@ def _load_attached(builder, detached): builder.session.attach_session() -def _load_detached(builder): +def _load_detached(builder: WorkspaceBuilder) -> None: """ Load config in new session but don't attach @@ -204,7 +221,7 @@ def _load_detached(builder): print("Session created in detached state.") -def _load_append_windows_to_current_session(builder): +def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: """ Load config as new windows in current session @@ -219,7 +236,7 @@ def _load_append_windows_to_current_session(builder): set_layout_hook(builder.session, "client-session-changed") -def _setup_plugins(builder): +def _setup_plugins(builder: WorkspaceBuilder) -> Session: """ Runs after before_script @@ -234,16 +251,16 @@ def _setup_plugins(builder): def load_workspace( - config_file, - socket_name=None, - socket_path=None, - tmux_config_file=None, - new_session_name=None, - colors=None, - detached=False, - answer_yes=False, - append=False, -): + config_file: t.Union[pathlib.Path, str], + socket_name: t.Optional[str] = None, + socket_path: None = None, + tmux_config_file: None = None, + new_session_name: t.Optional[str] = None, + colors: t.Optional[int] = None, + detached: bool = False, + answer_yes: bool = False, + append: bool = False, +) -> t.Optional[Session]: """ Load a tmux "workspace" session via tmuxp file. @@ -371,8 +388,8 @@ def load_workspace( sconf=sconfig, plugins=load_plugins(sconfig), server=t ) except exc.EmptyConfigException: - tmuxp_echo("%s is empty or parsed no config data" % config_file, err=True) - return + tmuxp_echo("%s is empty or parsed no config data" % config_file) + return None session_name = sconfig["session_name"] @@ -386,7 +403,7 @@ def load_workspace( ) ): _reattach(builder) - return + return None try: if detached: @@ -427,8 +444,8 @@ def load_workspace( except exc.TmuxpException as e: import traceback - tmuxp_echo(traceback.format_exc(), err=True) - tmuxp_echo(e, err=True) + tmuxp_echo(traceback.format_exc()) + tmuxp_echo(str(e)) choice = prompt_choices( "Error loading workspace. (k)ill, (a)ttach, (d)etach?", @@ -552,18 +569,9 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP def command_load( - config_file: t.Union[str, pathlib.Path], - socket_name: t.Optional[str] = None, - socket_path: t.Optional[str] = None, - tmux_config_file: t.Optional[str] = None, - new_session_name: t.Optional[str] = None, - answer_yes: t.Optional[bool] = None, - detached: t.Optional[bool] = None, - append: t.Optional[str] = None, - colors: t.Optional[str] = None, - log_file: t.Optional[str] = None, + args: CLILoadNamespace, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """Load a tmux workspace from each CONFIG. CONFIG is a specifier for a configuration file. @@ -588,34 +596,31 @@ def command_load( """ util.oh_my_zsh_auto_title() - if isinstance(config_file, str): - config_file = pathlib.Path(config_file) - - if log_file: - logfile_handler = logging.FileHandler(log_file) + if args.log_file: + logfile_handler = logging.FileHandler(args.log_file) logfile_handler.setFormatter(log.LogFormatter()) from . import logger logger.addHandler(logfile_handler) tmux_options = { - "socket_name": socket_name, - "socket_path": socket_path, - "tmux_config_file": tmux_config_file, - "new_session_name": new_session_name, - "answer_yes": answer_yes, - "colors": colors, - "detached": detached, - "append": append, + "socket_name": args.socket_name, + "socket_path": args.socket_path, + "tmux_config_file": args.tmux_config_file, + "new_session_name": args.new_session_name, + "answer_yes": args.answer_yes, + "colors": args.colors, + "detached": args.detached, + "append": args.append, } - if config_file is None: + if args.config_file is None: tmuxp_echo("Enter at least one config") if parser is not None: parser.print_help() sys.exit() - config_file = scan_config(config_file, config_dir=get_config_dir()) + config_file = scan_config(args.config_file, config_dir=get_config_dir()) if isinstance(config_file, str): load_workspace(config_file, **tmux_options) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index a342a2f78d2..e859652d3fd 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -14,7 +14,7 @@ def create_ls_subparser( def command_ls( parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: tmuxp_dir = get_config_dir() if os.path.exists(tmuxp_dir) and os.path.isdir(tmuxp_dir): for f in sorted(os.listdir(tmuxp_dir)): diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index 3a1c2c96bdb..f9cf6521e74 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -7,6 +7,27 @@ from .. import util from .._compat import PY3, PYMINOR +if t.TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias + + CLIColorsLiteral: TypeAlias = Literal[56, 88] + CLIShellLiteral: TypeAlias = Literal[ + "best", "pdb", "code", "ptipython", "ptpython", "ipython", "bpython" + ] + + +class CLIShellNamespace(argparse.Namespace): + session_name: str + socket_name: t.Optional[str] + socket_path: t.Optional[str] + colors: t.Optional["CLIColorsLiteral"] + log_file: t.Optional[str] + window_name: t.Optional[str] + command: t.Optional[str] + shell: t.Optional["CLIShellLiteral"] + use_pythonrc: bool + use_vi_mode: bool + def create_shell_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: parser.add_argument("session_name", metavar="session-name", nargs="?") @@ -107,16 +128,9 @@ def create_shell_subparser(parser: argparse.ArgumentParser) -> argparse.Argument def command_shell( - session_name, - window_name, - socket_name, - socket_path, - command: t.Optional[str] = None, - shell: t.Optional[str] = None, - use_pythonrc: bool = False, - use_vi_mode: bool = False, + args: CLIShellNamespace, parser: t.Optional[argparse.ArgumentParser] = None, -): +) -> None: """Launch python shell for tmux server, session, window and pane. Priority given to loaded session/window/pane objects: @@ -126,26 +140,28 @@ def command_shell( - :attr:`libtmux.Server.attached_sessions`, :attr:`libtmux.Session.attached_window`, :attr:`libtmux.Window.attached_pane` """ - server = Server(socket_name=socket_name, socket_path=socket_path) + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) util.raise_if_tmux_not_running(server=server) current_pane = util.get_current_pane(server=server) session = util.get_session( - server=server, session_name=session_name, current_pane=current_pane + server=server, session_name=args.session_name, current_pane=current_pane ) window = util.get_window( - session=session, window_name=window_name, current_pane=current_pane + session=session, window_name=args.window_name, current_pane=current_pane ) pane = util.get_pane(window=window, current_pane=current_pane) # NOQA: F841 - if command is not None: - exec(command) + if args.command is not None: + exec(args.command) else: - if shell == "pdb" or (os.getenv("PYTHONBREAKPOINT") and PY3 and PYMINOR >= 7): + if args.shell == "pdb" or ( + os.getenv("PYTHONBREAKPOINT") and PY3 and PYMINOR >= 7 + ): from tmuxp._compat import breakpoint as tmuxp_breakpoint tmuxp_breakpoint() @@ -154,9 +170,9 @@ def command_shell( from ..shell import launch launch( - shell=shell, - use_pythonrc=use_pythonrc, # shell: code - use_vi_mode=use_vi_mode, # shell: ptpython, ptipython + shell=args.shell, + use_pythonrc=args.use_pythonrc, # shell: code + use_vi_mode=args.use_vi_mode, # shell: ptpython, ptipython # tmux environment / libtmux variables server=server, session=session, diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index c750adfe36d..5b47f376c49 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -1,5 +1,6 @@ import logging import os +import pathlib import re import typing as t @@ -13,7 +14,7 @@ def tmuxp_echo( message: t.Optional[str] = None, - log_level="INFO", + log_level: str = "INFO", style_log: bool = False, ) -> None: """ @@ -30,7 +31,7 @@ def tmuxp_echo( print(message) -def get_config_dir(): +def get_config_dir() -> str: """ Return tmuxp configuration directory. @@ -62,7 +63,7 @@ def get_config_dir(): return path -def _validate_choices(options): +def _validate_choices(options: t.List[str]) -> t.Callable: """ Callback wrapper for validating click.prompt input. @@ -90,17 +91,18 @@ def func(value): class ConfigPath: - def __init__(self, config_dir=None, *args, **kwargs): + def __init__( + self, config_dir: t.Optional[t.Union[t.Callable, str]] = None, *args, **kwargs + ) -> None: super().__init__(*args, **kwargs) self.config_dir = config_dir - def convert(self, value, param, ctx): - config_dir = self.config_dir - if callable(config_dir): - config_dir = config_dir() + def convert( + self, value: str, param: t.Any, ctx: t.Any + ) -> t.Optional[t.Union[str, pathlib.Path]]: + config_dir = self.config_dir() if callable(self.config_dir) else self.config_dir - value = scan_config(value, config_dir=config_dir) - return super().convert(value, param, ctx) + return scan_config(value, config_dir=config_dir) def scan_config_argument(ctx, param, value, config_dir=None): @@ -112,7 +114,7 @@ def scan_config_argument(ctx, param, value, config_dir=None): if not config: tmuxp_echo("Enter at least one CONFIG") - tmuxp_echo(ctx.get_help(), color=ctx.color) + tmuxp_echo(ctx.get_help()) ctx.exit() if isinstance(value, str): @@ -124,7 +126,7 @@ def scan_config_argument(ctx, param, value, config_dir=None): return value -def get_abs_path(config): +def get_abs_path(config: str) -> str: path = os.path join, isabs = path.join, path.isabs dirname, normpath = path.dirname, path.normpath @@ -137,7 +139,10 @@ def get_abs_path(config): return config -def scan_config(config, config_dir=None): +def scan_config( + config: t.Union[pathlib.Path, str], + config_dir: t.Optional[t.Union[pathlib.Path, str]] = None, +) -> str: """ Return the real config path or raise an exception. @@ -234,7 +239,7 @@ def scan_config(config, config_dir=None): return config -def is_pure_name(path): +def is_pure_name(path: str) -> bool: """ Return True if path is a name and not a file path. @@ -332,12 +337,15 @@ def prompt_yes_no(name: str, default: bool = True) -> bool: return prompt_bool(name, default=default) +_C = t.TypeVar("_C") + + def prompt_choices( name: str, - choices: t.List[str], - default: t.Optional[str] = None, + choices: t.Union[t.List[_C], t.Tuple[str, _C]], + default: t.Optional[_C] = None, no_choice: t.Sequence[str] = ("none",), -): +) -> t.Optional[_C]: """Return user input from command line from set of provided choices. :param name: prompt text :param choices: list or tuple of available choices. Choices may be @@ -353,14 +361,14 @@ def prompt_choices( for choice in choices: if isinstance(choice, str): options.append(choice) - else: + elif isinstance(choice, tuple): options.append("%s [%s]" % (choice, choice[0])) choice = choice[0] _choices.append(choice) while True: - rv = prompt(name + " - (%s)" % ", ".join(options), default) - if not rv: + rv = prompt(name + " - (%s)" % ", ".join(options), default=default) + if not rv or rv == default: return default rv = rv.lower() if rv in no_choice: diff --git a/tests/test_cli.py b/tests/test_cli.py index a5dfd18dbce..78611498266 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,7 @@ import libtmux from libtmux.common import has_lt_version from libtmux.exc import LibTmuxException +from libtmux.session import Session from tmuxp import cli, config, exc from tmuxp.cli.import_config import get_teamocil_dir, get_tmuxinator_dir from tmuxp.cli.load import ( @@ -42,14 +43,14 @@ from libtmux.server import Server -def test_creates_config_dir_not_exists(tmp_path: pathlib.Path): +def test_creates_config_dir_not_exists(tmp_path: pathlib.Path) -> None: """cli.startup() creates config dir if not exists.""" cli.startup(tmp_path) assert os.path.exists(tmp_path) -def test_in_dir_from_config_dir(tmp_path: pathlib.Path): +def test_in_dir_from_config_dir(tmp_path: pathlib.Path) -> None: """config.in_dir() finds configs config dir.""" cli.startup(tmp_path) @@ -62,7 +63,7 @@ def test_in_dir_from_config_dir(tmp_path: pathlib.Path): assert len(configs_found) == 2 -def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path): +def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path) -> None: """cli.in_dir() ignore non-config from config dir.""" cli.startup(tmp_path) @@ -75,7 +76,9 @@ def test_ignore_non_configs_from_current_dir(tmp_path: pathlib.Path): assert len(configs_found) == 1 -def test_get_configs_cwd(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): +def test_get_configs_cwd( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: """config.in_cwd() find config in shell current working directory.""" confdir = tmp_path / "tmuxpconf2" @@ -106,7 +109,7 @@ def test_get_configs_cwd(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ("myproject", True), ], ) -def test_is_pure_name(path, expect): +def test_is_pure_name(path: str, expect: bool) -> None: assert is_pure_name(path) == expect @@ -130,33 +133,37 @@ def test_is_pure_name(path, expect): @pytest.fixture -def homedir(tmp_path: pathlib.Path): +def homedir(tmp_path: pathlib.Path) -> pathlib.Path: home = tmp_path / "home" home.mkdir() return home @pytest.fixture -def configdir(homedir): +def configdir(homedir: pathlib.Path) -> pathlib.Path: conf = homedir / ".tmuxp" conf.mkdir() return conf @pytest.fixture -def projectdir(homedir): +def projectdir(homedir: pathlib.Path) -> pathlib.Path: proj = homedir / "work" / "project" proj.mkdir(parents=True) return proj -def test_tmuxp_configdir_env_var(tmp_path: pathlib.Path, monkeypatch): +def test_tmuxp_configdir_env_var( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) assert get_config_dir() == str(tmp_path) -def test_tmuxp_configdir_xdg_config_dir(tmp_path: pathlib.Path, monkeypatch): +def test_tmuxp_configdir_xdg_config_dir( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) tmux_dir = tmp_path / "tmuxp" tmux_dir.mkdir() @@ -170,7 +177,7 @@ def test_resolve_dot( configdir: pathlib.Path, projectdir: pathlib.Path, monkeypatch: pytest.MonkeyPatch, -): +) -> None: monkeypatch.setenv("HOME", str(homedir)) monkeypatch.setenv("XDG_CONFIG_HOME", str(homedir / ".config")) @@ -272,12 +279,12 @@ def test_resolve_dot( def test_scan_config_arg( - homedir, - configdir, - projectdir, + homedir: pathlib.Path, + configdir: pathlib.Path, + projectdir: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: parser = argparse.ArgumentParser() parser.add_argument("config_file", type=str) @@ -320,7 +327,7 @@ def check_cmd(config_arg) -> "_pytest.capture.CaptureResult": assert "config not found in config dir" in check_cmd("moo").err -def test_load_workspace(server, monkeypatch): +def test_load_workspace(server: "Server", monkeypatch: pytest.MonkeyPatch) -> None: # this is an implementation test. Since this testsuite may be ran within # a tmux session by the developer himself, delete the TMUX variable # temporarily. @@ -332,11 +339,13 @@ def test_load_workspace(server, monkeypatch): session_file, socket_name=server.socket_name, detached=True ) - assert isinstance(session, libtmux.Session) + assert isinstance(session, Session) assert session.name == "sampleconfig" -def test_load_workspace_named_session(server, monkeypatch): +def test_load_workspace_named_session( + server: "Server", monkeypatch: pytest.MonkeyPatch +) -> None: # this is an implementation test. Since this testsuite may be ran within # a tmux session by the developer himself, delete the TMUX variable # temporarily. @@ -351,7 +360,7 @@ def test_load_workspace_named_session(server, monkeypatch): detached=True, ) - assert isinstance(session, libtmux.Session) + assert isinstance(session, Session) assert session.name == "tmuxp-new" @@ -359,8 +368,8 @@ def test_load_workspace_named_session(server, monkeypatch): has_lt_version("2.1"), reason="exact session name matches only tmux >= 2.1" ) def test_load_workspace_name_match_regression_252( - tmp_path: pathlib.Path, server, monkeypatch -): + tmp_path: pathlib.Path, server: "Server", monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.delenv("TMUX", raising=False) session_file = FIXTURE_PATH / "workspacebuilder" / "two_pane.yaml" @@ -369,7 +378,7 @@ def test_load_workspace_name_match_regression_252( session_file, socket_name=server.socket_name, detached=True ) - assert isinstance(session, libtmux.Session) + assert isinstance(session, Session) assert session.name == "sampleconfig" projfile = tmp_path / "simple.yaml" @@ -388,10 +397,13 @@ def test_load_workspace_name_match_regression_252( session = load_workspace( str(projfile), socket_name=server.socket_name, detached=True ) + assert session is not None assert session.name == "sampleconfi" -def test_load_symlinked_workspace(server, tmp_path, monkeypatch): +def test_load_symlinked_workspace( + server: "Server", tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: # this is an implementation test. Since this testsuite may be ran within # a tmux session by the developer himself, delete the TMUX variable # temporarily. @@ -417,19 +429,23 @@ def test_load_symlinked_workspace(server, tmp_path, monkeypatch): session = load_workspace( str(projfile), socket_name=server.socket_name, detached=True ) + assert session is not None + assert session.attached_window is not None pane = session.attached_window.attached_pane - assert isinstance(session, libtmux.Session) + assert isinstance(session, Session) assert session.name == "samplesimple" + + assert pane is not None assert pane.current_path == str(realtemp) def test_regression_00132_session_name_with_dots( tmp_path: pathlib.Path, - server, - session, + server: "Server", + session: Session, capsys: pytest.CaptureFixture, -): +) -> None: yaml_config = FIXTURE_PATH / "workspacebuilder" / "regression_00132_dots.yaml" cli_args = [str(yaml_config)] with pytest.raises(libtmux.exc.BadSessionName): @@ -499,8 +515,11 @@ def test_load_zsh_autotitle_warning( ], ) def test_load_log_file( - cli_args, tmp_path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture -): + cli_args: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, +) -> None: # create dummy tmuxp yaml that breaks to prevent actually loading tmux tmuxp_config_path = tmp_path / ".tmuxp.yaml" tmuxp_config_path.write_text( @@ -526,7 +545,7 @@ def test_load_log_file( assert result.out is not None -@pytest.mark.parametrize("cli_cmd", ["shell", ("shell", "--pdb")]) +@pytest.mark.parametrize("cli_cmd", [["shell"], ["shell", "--pdb"]]) @pytest.mark.parametrize( "cli_args,inputs,env,expected_output", [ @@ -596,32 +615,32 @@ def test_load_log_file( ], ) def test_shell( - cli_cmd, - cli_args, - inputs, - expected_output, - env, - server, - session, + cli_cmd: t.List[str], + cli_args: t.List[str], + inputs: t.List[t.Any], + expected_output: str, + env: t.Dict[str, str], + server: "Server", + session: Session, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" window = session.new_window(window_name=window_name) window.split_window() + assert window.attached_pane is not None + template_ctx = dict( SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, SESSION_NAME=session.name, WINDOW_NAME=window_name, PANE_ID=window.attached_pane.id, SERVER_SOCKET_NAME=server.socket_name, ) - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] for k, v in env.items(): @@ -637,8 +656,8 @@ def test_shell( @pytest.mark.parametrize( "cli_cmd", [ - "shell", - ("shell", "--pdb"), + ["shell"], + ["shell", "--pdb"], ], ) @pytest.mark.parametrize( @@ -682,34 +701,35 @@ def test_shell( ], ) def test_shell_target_missing( - cli_cmd, - cli_args, - inputs, - env, - template_ctx, - exception, - message, - socket_name, - server, - session, + cli_cmd: t.List[str], + cli_args: t.List[str], + inputs: t.List[t.Any], + env: t.Dict[t.Any, t.Any], + template_ctx: t.Dict[str, str], + exception: t.Union[t.Type[exc.TmuxpException], t.Type[LibTmuxException]], + message: str, + socket_name: str, + server: "Server", + session: Session, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" window = session.new_window(window_name=window_name) window.split_window() - template_ctx = dict( - SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, - SESSION_NAME=session.name, - WINDOW_NAME=template_ctx.get("window_name", window_name), - PANE_ID=template_ctx.get("pane_id"), - SERVER_SOCKET_NAME=server.socket_name, + assert server.socket_name is not None + assert session.name is not None + + template_ctx.update( + dict( + SOCKET_NAME=server.socket_name, + SESSION_NAME=session.name, + WINDOW_NAME=template_ctx.get("window_name", window_name), + ) ) - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] for k, v in env.items(): @@ -729,13 +749,13 @@ def test_shell_target_missing( @pytest.mark.parametrize( "cli_cmd", [ - # 'shell', - # ('shell', '--pdb'), - ("shell", "--code"), - # ('shell', '--bpython'), - # ('shell', '--ptipython'), - # ('shell', '--ptpython'), - # ('shell', '--ipython'), + # ['shell'], + # ['shell', '--pdb'), + ["shell", "--code"], + # ['shell', '--bpython'], + # ['shell', '--ptipython'], + # ['shell', '--ptpython'], + # ['shell', '--ipython'], ], ) @pytest.mark.parametrize( @@ -760,32 +780,32 @@ def test_shell_target_missing( ], ) def test_shell_interactive( - cli_cmd, - cli_args, - inputs, - env, - message, - server, - session, + cli_cmd: t.List[str], + cli_args: t.List[str], + inputs: t.List[t.Any], + env: t.Dict[str, str], + message: str, + server: "Server", + session: Session, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) window_name = "my_window" window = session.new_window(window_name=window_name) window.split_window() + assert window.attached_pane is not None + template_ctx = dict( SOCKET_NAME=server.socket_name, - SOCKET_PATH=server.socket_path, SESSION_NAME=session.name, WINDOW_NAME=window_name, PANE_ID=window.attached_pane.id, SERVER_SOCKET_NAME=server.socket_name, ) - cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] for k, v in env.items(): @@ -812,10 +832,10 @@ def test_shell_interactive( ], ) def test_convert( - cli_args, + cli_args: t.List[str], tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, -): +) -> None: # create dummy tmuxp yaml so we don't get yelled at filename = cli_args[1] if filename == ".": @@ -852,10 +872,10 @@ def test_convert( ], ) def test_convert_json( - cli_args, + cli_args: t.List[str], tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, -): +) -> None: # create dummy tmuxp yaml so we don't get yelled at json_config = tmp_path / ".tmuxp.json" json_config.write_text('{"session_name": "hello"}', encoding="utf-8") @@ -881,11 +901,11 @@ def test_convert_json( @pytest.mark.parametrize("cli_args", [(["import"])]) def test_import( - cli_args, + cli_args: t.List[str], tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: cli.cli(cli_args) result = capsys.readouterr() assert "tmuxinator" in result.out @@ -900,11 +920,11 @@ def test_import( ], ) def test_help( - cli_args, + cli_args: t.List[str], tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: try: cli.cli(cli_args) except SystemExit: @@ -932,8 +952,11 @@ def test_help( ], ) def test_import_teamocil( - cli_args, inputs, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch -): + cli_args: t.List[str], + inputs: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: teamocil_config = test_utils.read_config_file("config_teamocil/test4.yaml") teamocil_path = tmp_path / ".teamocil" @@ -976,7 +999,12 @@ def test_import_teamocil( ), ], ) -def test_import_tmuxinator(cli_args, inputs, tmp_path, monkeypatch): +def test_import_tmuxinator( + cli_args: t.List[str], + inputs: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: tmuxinator_config = test_utils.read_config_file("config_tmuxinator/test3.yaml") tmuxinator_path = tmp_path / ".tmuxinator" @@ -1018,7 +1046,11 @@ def test_import_tmuxinator(cli_args, inputs, tmp_path, monkeypatch): ], ) def test_freeze( - server, cli_args, inputs, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch + server: "Server", + cli_args: t.List[str], + inputs: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) exists_yaml = tmp_path / "exists.yaml" @@ -1036,6 +1068,7 @@ def test_freeze( monkeypatch.chdir(tmp_path) # Use tmux server (socket name) used in the test + assert server.socket_name is not None cli_args = cli_args + ["-L", server.socket_name] monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) @@ -1066,7 +1099,13 @@ def test_freeze( ), ], ) -def test_freeze_overwrite(server, cli_args, inputs, tmp_path, monkeypatch): +def test_freeze_overwrite( + server: "Server", + cli_args: t.List[str], + inputs: t.List[str], + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("HOME", str(tmp_path)) exists_yaml = tmp_path / "exists.yaml" exists_yaml.touch() @@ -1075,6 +1114,7 @@ def test_freeze_overwrite(server, cli_args, inputs, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # Use tmux server (socket name) used in the test + assert server.socket_name is not None cli_args = cli_args + ["-L", server.socket_name] monkeypatch.setattr("sys.stdin", io.StringIO("".join(inputs))) @@ -1087,7 +1127,7 @@ def test_freeze_overwrite(server, cli_args, inputs, tmp_path, monkeypatch): assert yaml_config_path.exists() -def test_get_abs_path(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): +def test_get_abs_path(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: expect = str(tmp_path) monkeypatch.chdir(tmp_path) get_abs_path("../") == os.path.dirname(expect) @@ -1096,7 +1136,7 @@ def test_get_abs_path(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): get_abs_path(expect) == expect -def test_get_tmuxinator_dir(monkeypatch): +def test_get_tmuxinator_dir(monkeypatch: pytest.MonkeyPatch) -> None: assert get_tmuxinator_dir() == os.path.expanduser("~/.tmuxinator/") monkeypatch.setenv("HOME", "/moo") @@ -1104,7 +1144,7 @@ def test_get_tmuxinator_dir(monkeypatch): assert get_tmuxinator_dir() == os.path.expanduser("~/.tmuxinator/") -def test_get_teamocil_dir(monkeypatch: pytest.MonkeyPatch): +def test_get_teamocil_dir(monkeypatch: pytest.MonkeyPatch) -> None: assert get_teamocil_dir() == os.path.expanduser("~/.teamocil/") monkeypatch.setenv("HOME", "/moo") @@ -1112,7 +1152,7 @@ def test_get_teamocil_dir(monkeypatch: pytest.MonkeyPatch): assert get_teamocil_dir() == os.path.expanduser("~/.teamocil/") -def test_validate_choices(): +def test_validate_choices() -> None: validate = _validate_choices(["choice1", "choice2"]) assert validate("choice1") @@ -1126,7 +1166,7 @@ def test_pass_config_dir_ClickPath( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, -): +) -> None: configdir = tmp_path / "myconfigdir" configdir.mkdir() @@ -1197,7 +1237,7 @@ def test_ls_cli( assert cli_output == "\n".join(stems) + "\n" -def test_load_plugins(monkeypatch_plugin_test_packages): +def test_load_plugins(monkeypatch_plugin_test_packages: None) -> None: from tmuxp_test_plugin_bwb.plugin import PluginBeforeWorkspaceBuilder plugins_config = test_utils.read_config_file("workspacebuilder/plugin_bwb.yaml") @@ -1248,9 +1288,9 @@ def test_load_plugins_version_fail_skip( ], ) def test_load_plugins_version_fail_no_skip( - monkeypatch_plugin_test_packages, - cli_args, - inputs, + monkeypatch_plugin_test_packages: None, + cli_args: t.List[str], + inputs: t.List[str], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: @@ -1269,7 +1309,9 @@ def test_load_plugins_version_fail_no_skip( "cli_args", [(["load", "tests/fixtures/workspacebuilder/plugin_missing_fail.yaml"])] ) def test_load_plugins_plugin_missing( - monkeypatch_plugin_test_packages, cli_args, capsys: pytest.CaptureFixture + monkeypatch_plugin_test_packages: None, + cli_args: t.List[str], + capsys: pytest.CaptureFixture, ) -> None: try: cli.cli(cli_args) @@ -1281,8 +1323,10 @@ def test_load_plugins_plugin_missing( def test_plugin_system_before_script( - monkeypatch_plugin_test_packages, server, monkeypatch -): + monkeypatch_plugin_test_packages: None, + server: "Server", + monkeypatch: pytest.MonkeyPatch, +) -> None: # this is an implementation test. Since this testsuite may be ran within # a tmux session by the developer himself, delete the TMUX variable # temporarily. @@ -1298,7 +1342,9 @@ def test_plugin_system_before_script( assert session.name == "plugin_test_bs" -def test_reattach_plugins(monkeypatch_plugin_test_packages, server): +def test_reattach_plugins( + monkeypatch_plugin_test_packages: None, server: "Server" +) -> None: config_plugins = test_utils.read_config_file("workspacebuilder/plugin_r.yaml") sconfig = ConfigReader._load(format="yaml", content=config_plugins) @@ -1396,7 +1442,9 @@ def test_load_attached_within_tmux_detached( assert switch_client_mock.call_count == 1 -def test_load_append_windows_to_current_session(server, monkeypatch): +def test_load_append_windows_to_current_session( + server: "Server", monkeypatch: pytest.MonkeyPatch +) -> None: yaml_config = test_utils.read_config_file("workspacebuilder/two_pane.yaml") sconfig = ConfigReader._load(format="yaml", content=yaml_config) @@ -1417,7 +1465,9 @@ def test_load_append_windows_to_current_session(server, monkeypatch): def test_debug_info_cli( - monkeypatch, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture, ) -> None: monkeypatch.setenv("SHELL", "/bin/bash") From afb7af3626b351074c3c77de3e9631bf8435e963 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 9 Oct 2022 17:37:04 -0500 Subject: [PATCH 9/9] docs(CHANGES,MIGRATION): Update for argparse change --- CHANGES | 21 ++++++++++++++++++++- MIGRATION | 15 ++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index caa0e694f8e..578943f088a 100644 --- a/CHANGES +++ b/CHANGES @@ -17,7 +17,26 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force ## tmuxp 1.17.x (unreleased) -- _Insert changes/features/fixes for next release here_ +- Notes on upcoming releases will be added here + + + +### Breaking changes + +- **Completions have changed** (#830) + + Completions now use a different tool: [shtab]. See the [completions page] for more information. + + If you were using earlier versions of tmuxp (earlier than 1.17.0), you may need to uninstall the old completions, first. + + [completions page]: https://tmuxp.git-pull.com/cli/completion.html + [shtab]: https://docs.iterative.ai/shtab/ + +- Deprecate `click` in favor of {mod}`argparse` (#830) + +### Packages + +- Remove `click` dependency ## tmuxp 1.16.2 (2022-10-08) diff --git a/MIGRATION b/MIGRATION index 9ca91daf17f..0bfa27523b9 100644 --- a/MIGRATION +++ b/MIGRATION @@ -21,7 +21,20 @@ well. ## Next release -_Add migration notes here_ +_Notes on the upcoming release will be added here_ + + + +## 1.17.x (unreleased) + +**Completions have changed** (#830) + +Completions now use a different tool: [shtab]. See the [completions page] for more information. + +If you were using earlier versions of tmuxp (earlier than 1.17.0), you may need to uninstall the old completions, first. + +[completions page]: https://tmuxp.git-pull.com/cli/completion.html +[shtab]: https://docs.iterative.ai/shtab/