From 408da2c522b60492fb72c1f3cc8efcfc97484fd1 Mon Sep 17 00:00:00 2001 From: Stryder Date: Mon, 9 May 2022 11:31:19 -0400 Subject: [PATCH] Add KeyedList question type along with render, configuration, and tests. KeyedList derives its behavior from List but allows the developer to define an explicit key press associated with each element that a user can use to directly select that entry vs. key Up/Down. KeyedList supports an `auto_confirm` argument that allows an entry to be selected just by the corresponding key. Also supports multiple key matches by jumping through selections on each key press. --- README.rst | 32 ++ poetry.lock | 144 ++++++- pyproject.toml | 1 + src/inquirer/questions.py | 37 +- src/inquirer/render/console/__init__.py | 2 + src/inquirer/render/console/_keyed_list.py | 34 ++ src/inquirer/shortcuts.py | 6 + .../console_render/test_keyed_list.py | 350 ++++++++++++++++++ tests/unit/test_question.py | 48 +++ tests/unit/test_shortcuts.py | 1 + 10 files changed, 647 insertions(+), 8 deletions(-) create mode 100644 src/inquirer/render/console/_keyed_list.py create mode 100644 tests/integration/console_render/test_keyed_list.py diff --git a/README.rst b/README.rst index 5c5f2440..abf4f271 100644 --- a/README.rst +++ b/README.rst @@ -105,6 +105,38 @@ List questions can take one extra argument :code:`carousel=False`. If set to tru |inquirer list| +Keyed List +---- + +Shows a list of choices, and allows the selection of one of them using a corresponding key. + +Example: + +.. code:: python + + + import inquirer + questions = [ + inquirer.KeyedList('size', + message="What size do you need?", + choices=['Jumbo', 'Large', 'Standard', 'Medium', 'Small', 'Micro'], + ), + ] + answers = inquirer.prompt(questions) + +Keyed List choices use the first letter (lower case) or number of the provided choice by default. If multiple choices +share the same key, repeated presses of the same key will cycle through the matching entries. +Keyed Lists are otherwise similar to List but have one more additional argument, +:code:`auto_confirm=False`. If set to True, a key press with a matching entry will cause that entry to be instantly selected. + +Keys can explicitly be set by providing the label, value and key as a tuple: +:code:`choices=[('Jumbo', 'Jumbo', 'u'), ('Large', 'Large', 'a'), ...]` + + + +|inquirer list| + + Checkbox -------- diff --git a/poetry.lock b/poetry.lock index 05e8707c..9dcdb1a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,28 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + [[package]] name = "babel" version = "2.9.1" @@ -76,7 +98,7 @@ unicode_backport = ["unicodedata2"] name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -164,7 +186,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "importlib-metadata" version = "4.2.0" description = "Read metadata from Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -176,6 +198,14 @@ zipp = ">=0.5" docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "jinja2" version = "3.0.3" @@ -233,7 +263,7 @@ python-versions = "*" name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -251,6 +281,21 @@ python-versions = "*" [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "pre-commit-hooks" version = "4.1.0" @@ -271,6 +316,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "pycodestyle" version = "2.8.0" @@ -313,13 +366,35 @@ python-versions = ">=3.5" name = "pyparsing" version = "3.0.6" description = "Python parsing module" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + [[package]] name = "python-editor" version = "1.0.4" @@ -546,6 +621,14 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "tornado" version = "6.1" @@ -558,7 +641,7 @@ python-versions = ">= 3.5" name = "typing-extensions" version = "4.0.1" description = "Backported and Experimental Type Hints for Python 3.6+" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -586,7 +669,7 @@ python-versions = "*" name = "zipp" version = "3.6.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -597,7 +680,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = ">=3.7" -content-hash = "cf8f7bf13cb0e983390dc7142e282399c81851b25f883b675d8cae6f9c0d495b" +content-hash = "6bde1042e4ce539716a6a94ec06319d03d7871483042361b7a6061ec7d218740" [metadata.files] alabaster = [ @@ -608,6 +691,14 @@ ansicon = [ {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, ] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, @@ -664,6 +755,10 @@ importlib-metadata = [ {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, ] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] jinja2 = [ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, @@ -681,6 +776,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, @@ -692,6 +790,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -703,6 +804,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, @@ -715,6 +819,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -727,6 +834,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -743,6 +853,10 @@ pexpect = [ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, ] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] pre-commit-hooks = [ {file = "pre_commit_hooks-4.1.0-py2.py3-none-any.whl", hash = "sha256:ba95316b79038e56ce998cdacb1ce922831ac0e41744c77bcc2b9677bf183206"}, {file = "pre_commit_hooks-4.1.0.tar.gz", hash = "sha256:b6361865d1877c5da5ac3a944aab19ce6bd749a534d2ede28e683d07194a57e1"}, @@ -751,6 +865,10 @@ ptyprocess = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] pycodestyle = [ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, @@ -771,6 +889,10 @@ pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] python-editor = [ {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"}, {file = "python_editor-1.0.4-py2-none-any.whl", hash = "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"}, @@ -798,6 +920,10 @@ restructuredtext-lint = [ {file = "ruamel.yaml-0.17.19.tar.gz", hash = "sha256:b9ce9a925d0f0c35a1dbba56b40f253c53cd526b0fa81cf7b1d24996f28fb1d7"}, ] "ruamel.yaml.clib" = [ + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e7be2c5bcb297f5b82fee9c665eb2eb7001d1050deaba8471842979293a80b0"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:221eca6f35076c6ae472a531afa1c223b9c29377e62936f61bc8e6e8bdc5f9e7"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win32.whl", hash = "sha256:1070ba9dd7f9370d0513d649420c3b362ac2d687fe78c6e888f5b12bf8bc7bee"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:77df077d32921ad46f34816a9a16e6356d8100374579bc35e15bab5d4e9377de"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"}, {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"}, @@ -868,6 +994,10 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] tornado = [ {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"}, {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"}, diff --git a/pyproject.toml b/pyproject.toml index 72a4e58e..0ff2e34e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ python = ">=3.7" blessed = ">=1.19.0" readchar = ">=2.0.1" python-editor = ">=1.0.4" +pytest = "^7.1.2" [tool.poetry.dev-dependencies] pexpect = ">=4.8.0" diff --git a/src/inquirer/questions.py b/src/inquirer/questions.py index 12d32c67..ca445b60 100644 --- a/src/inquirer/questions.py +++ b/src/inquirer/questions.py @@ -4,6 +4,7 @@ import errno import json import os +import re import sys import inquirer.errors as errors @@ -29,6 +30,25 @@ def __ne__(self, other): return not self.__eq__(other) +class KeyedValue(TaggedValue): + def __init__(self, label, value=None, key=None): + self.label = label + self.value = value or label + self.key = key or self._get_key(label) + + def _get_key(self, label): + _label = str(label) + k = re.search(r'([a-zA-Z\d])', _label) + if k: + return str(k.group(0)).lower() + return _label[0] + + def __str__(self): + return str(self.label) + + def __repr__(self): + return str(self.value) + class Question: kind = "base question" @@ -118,6 +138,21 @@ def __init__(self, name, message="", choices=None, default=None, ignore=False, v self.carousel = carousel +class KeyedList(Question): + kind = "keyed_list" + + def __init__(self, name, message="", choices=None, default=None, ignore=False, validate=True, carousel=False,auto_confirm=False): + super().__init__(name, message, choices, default, ignore, validate) + self.carousel = carousel + self.auto_confirm = auto_confirm # Auto Confirm selection on keypress + + + @property + def choices_generator(self): + for choice in self._solve(self._choices): + yield (KeyedValue(*choice) if isinstance(choice, (list, tuple, set)) and len(choice) >= 2 else KeyedValue(choice)) + + class Checkbox(Question): kind = "checkbox" @@ -227,7 +262,7 @@ def normalize_value(self, value): def question_factory(kind, *args, **kwargs): - for cl in (Text, Editor, Password, Confirm, List, Checkbox, Path): + for cl in (Text, Editor, Password, Confirm, List, KeyedList, Checkbox, Path): if cl.kind == kind: return cl(*args, **kwargs) raise errors.UnknownQuestionTypeError() diff --git a/src/inquirer/render/console/__init__.py b/src/inquirer/render/console/__init__.py index 74fdebad..4cf024f4 100644 --- a/src/inquirer/render/console/__init__.py +++ b/src/inquirer/render/console/__init__.py @@ -10,6 +10,7 @@ from inquirer.render.console._password import Password from inquirer.render.console._confirm import Confirm from inquirer.render.console._list import List +from inquirer.render.console._keyed_list import KeyedList from inquirer.render.console._checkbox import Checkbox from inquirer.render.console._path import Path @@ -146,6 +147,7 @@ def render_factory(self, question_type): "password": Password, "confirm": Confirm, "list": List, + "keyed_list": KeyedList, "checkbox": Checkbox, "path": Path, } diff --git a/src/inquirer/render/console/_keyed_list.py b/src/inquirer/render/console/_keyed_list.py new file mode 100644 index 00000000..fb929fc8 --- /dev/null +++ b/src/inquirer/render/console/_keyed_list.py @@ -0,0 +1,34 @@ +from readchar import key + +from inquirer import errors +from inquirer.render.console.base import MAX_OPTIONS_DISPLAYED_AT_ONCE +from inquirer.render.console.base import BaseConsoleRender +from inquirer.render.console.base import half_options +from inquirer.render.console._list import List + + +class KeyedList(List): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.current = self._current_index() + + def process_input(self, pressed): + super().process_input(pressed) + + keys = [choice.key for choice in self.question.choices] + + if pressed in keys: + self.current = self.get_next(keys, pressed) + + if self.question.auto_confirm: + value = self.question.choices[self.current] + + raise errors.EndOfInput(getattr(value, "value", value)) + + def get_next(self, keys, pressed): + try: + # Multiple entries with the same key? Get the 'next' one. + return keys.index(pressed, self.current + 1) + except ValueError: + # There isn't a next one, so get the first. + return keys.index(pressed) diff --git a/src/inquirer/shortcuts.py b/src/inquirer/shortcuts.py index 1bca921c..6ee40850 100644 --- a/src/inquirer/shortcuts.py +++ b/src/inquirer/shortcuts.py @@ -42,3 +42,9 @@ def path(message, render=None, **kwargs): render = render or ConsoleRender() question = questions.Path(name="", message=message, **kwargs) return render.render(question) + + +def keyed_list_input(message, render=None, **kwargs): + render = render or ConsoleRender() + question = questions.KeyedList(name="", message=message, **kwargs) + return render.render(question) diff --git a/tests/integration/console_render/test_keyed_list.py b/tests/integration/console_render/test_keyed_list.py new file mode 100644 index 00000000..465d4302 --- /dev/null +++ b/tests/integration/console_render/test_keyed_list.py @@ -0,0 +1,350 @@ +import unittest + +import pytest +from readchar import key + +import inquirer.questions as questions +import tests.integration.console_render.helper as helper +from inquirer.render import ConsoleRender + + +class KeyedListRenderTest(unittest.TestCase, helper.BaseTestCase): + def setUp(self): + self.base_setup() + + def tearDown(self): + self.base_teardown() + + def test_all_choices_are_shown(self): + stdin = helper.event_factory(key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices) + + sut = ConsoleRender(event_generator=stdin) + sut.render(question) + + self.assertInStdout(message) + for choice in choices: + self.assertInStdout(choice) + + def test_choose_the_first(self): + stdin = helper.event_factory(key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_choose_the_second(self): + stdin = helper.event_factory(key.DOWN, key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "bar" + + def test_choose_with_long_choices(self): + stdin = helper.event_factory( + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.DOWN, + key.ENTER, + ) + message = "Number message" + variable = "Number variable" + choices = list(range(15)) + + question = questions.KeyedList(variable, message, choices=choices) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == 10 + + def test_move_up(self): + stdin = helper.event_factory(key.DOWN, key.UP, key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_move_down_carousel(self): + stdin = helper.event_factory(key.DOWN, key.DOWN, key.DOWN, key.DOWN, + key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "bar" + + def test_move_up_carousel(self): + stdin = helper.event_factory(key.UP, key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "bazz"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "bazz" + + def test_ctrl_c_breaks_execution(self): + stdin_array = [key.CTRL_C] + stdin = helper.event_factory(*stdin_array) + message = "Foo message" + variable = "Bar variable" + + question = questions.KeyedList(variable, message) + + sut = ConsoleRender(event_generator=stdin) + with pytest.raises(KeyboardInterrupt): + sut.render(question) + + def test_select_by_default_key_with_carousel_no_auto(self): + stdin = helper.event_factory(key.UP, key.UP, "f", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "cat", "dog"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_select_by_default_key_with_no_carousel_no_auto(self): + stdin = helper.event_factory(key.UP, key.DOWN, "d", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "cat", "dog"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "dog" + + def test_select_by_explicit_key_with_carousel_no_auto(self): + stdin = helper.event_factory(key.UP, key.UP, "f", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = [ + ("foo", "foo", "f"), + ("bar", "bar", "b"), + ("cat", "cat", "c"), + ("dog", "dog", "d") + ] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_select_by_explicit_key_with_no_carousel_no_auto(self): + stdin = helper.event_factory(key.UP, key.DOWN, "d", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = [ + ("foo", "foo", "f"), + ("bar", "bar", "b"), + ("cat", "cat", "c"), + ("dog", "dog", "d") + ] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "dog" + + def test_select_by_default_key_with_carousel_auto(self): + stdin = helper.event_factory(key.UP, key.UP, "f") + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "cat", "dog"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True, + auto_confirm=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_select_by_default_key_with_no_carousel_auto(self): + stdin = helper.event_factory(key.UP, key.DOWN, "d") + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "cat", "dog"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True, + auto_confirm=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "dog" + + def test_select_by_explicit_key_with_carousel_auto(self): + stdin = helper.event_factory(key.UP, key.UP, "f") + message = "Foo message" + variable = "Bar variable" + choices = [ + ("foo", "foo", "f"), + ("bar", "bar", "b"), + ("cat", "cat", "c"), + ("dog", "dog", "d") + ] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True, + auto_confirm=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "foo" + + def test_select_by_explicit_key_with_no_carousel_auto(self): + stdin = helper.event_factory(key.UP, key.DOWN, "d") + message = "Foo message" + variable = "Bar variable" + choices = [ + ("foo", "foo", "f"), + ("bar", "bar", "b"), + ("cat", "cat", "c"), + ("dog", "dog", "d") + ] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True, + auto_confirm=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "dog" + + def test_select_by_default_key_multiple_matches(self): + stdin = helper.event_factory(key.UP, key.UP, "b", "b", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "baz", "cat", "dog", "car"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "baz" + + def test_select_by_default_key_multiple_matches_variation(self): + stdin = helper.event_factory(key.UP, key.UP, "b", "c", "b", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["foo", "bar", "baz", "cat", "dog", "car"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "bar" + + def test_select_by_default_numeric_key_variation(self): + stdin = helper.event_factory(key.UP, key.UP, "1", "4", "3", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["1. foo", "2. bar", "3. baz", "4. cat", "5. dog", "6. car"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "3. baz" + + def test_select_by_default_numeric_key_multiple_matches_variation(self): + stdin = helper.event_factory(key.UP, key.UP, "1", "3", "1", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["1. foo", "2. bar", "3. baz", "4. cat", "1. dog", "6. car"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "1. dog" + + def test_select_by_default_numeric_key_variation_auto_select(self): + stdin = helper.event_factory(key.UP, key.UP, "1", "4", "3", key.ENTER) + message = "Foo message" + variable = "Bar variable" + choices = ["1. foo", "2. bar", "3. baz", "4. cat", "5. dog", "6. car"] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "3. baz" + + def test_select_by_default_numeric_key_explicit_value_auto_select_(self): + stdin = helper.event_factory(key.UP, key.UP, "4") + message = "Foo message" + variable = "Bar variable" + choices = [ + ("1. foo", "foo"), + ("2. bar", "bar"), + ("3. baz", "baz"), + ("4. cat", "cat"), + ("1. dog", "dog"), + ("6. car", "car") + ] + + question = questions.KeyedList(variable, message, choices=choices, + carousel=True, + auto_confirm=True) + sut = ConsoleRender(event_generator=stdin) + result = sut.render(question) + + assert result == "cat" diff --git a/tests/unit/test_question.py b/tests/unit/test_question.py index 02cc2aa5..7423b2e4 100644 --- a/tests/unit/test_question.py +++ b/tests/unit/test_question.py @@ -362,6 +362,45 @@ def test_default_value_validation(self): questions.Path("path", default="~/.toggl_log") +class TestKeyedListQuestion(unittest.TestCase): + + def test_keyed_value_inits(self): + question = questions.KeyedValue("label") + assert question.label == "label" + assert question.value == "label" + assert question.key == "l" + + question = questions.KeyedValue("[l]abel") + assert question.label == "[l]abel" + assert question.value == "[l]abel" + assert question.key == "l" + + question = questions.KeyedValue("_Label") + assert question.label == "_Label" + assert question.value == "_Label" + assert question.key == "l" + + question = questions.KeyedValue("_label", 'o') + assert question.label == "_label" + assert question.value == "o" + assert question.key == "l" + + question = questions.KeyedValue("_label", "value", 'l') + assert question.label == "_label" + assert question.value == "value" + assert question.key == "l" + + question = questions.KeyedValue("_label", "value", 'o') + assert question.label == "_label" + assert question.value == "value" + assert question.key == "o" + + question = questions.KeyedValue("label", "value", 'o') + assert question.label == "label" + assert question.value == "value" + assert question.key == "o" + + def test_tagged_value(): tv = questions.TaggedValue("label", "value") @@ -370,3 +409,12 @@ def test_tagged_value(): assert tv.__eq__(tv) is True assert tv.__eq__("") is False assert tv.__ne__(tv) is False + + +def test_keyed_value_builtin_methods(): + ktv = questions.KeyedValue("label", "value", "l") + assert ktv.__str__() == "label" + assert ktv.__repr__() == "value" + assert ktv.__eq__(ktv) is True + assert ktv.__eq__("") is False + assert ktv.__ne__(ktv) is False diff --git a/tests/unit/test_shortcuts.py b/tests/unit/test_shortcuts.py index cebc344f..f7984fac 100644 --- a/tests/unit/test_shortcuts.py +++ b/tests/unit/test_shortcuts.py @@ -22,6 +22,7 @@ def render_mock(): (shortcuts.list_input, "list", "list_input message"), (shortcuts.checkbox, "checkbox", "checkbox message"), (shortcuts.path, "path", "path message"), + (shortcuts.keyed_list_input, "keyed_list", "keyed_list_input message") ], ) def test_shortcuts(func, kind, message, render_mock):