├── setup.cfg ├── tests ├── __init__.py ├── test_menu │ ├── test_with_valid_entry_single_with_key_.yml │ ├── test_with_valid_entry_single_without_key_.yml │ ├── test_with_valid_entry_multiple_.yml │ ├── test_method_collect_input_with_valid_input_single_with_key_.yml │ ├── test_method_collect_input_with_valid_input_single_without_key_.yml │ ├── test_method_collect_input_with_valid_input_multiple_.yml │ ├── test_method_print_menu_single_with_key_.yml │ ├── test_method_print_menu_single_without_key_.yml │ ├── test_method_print_menu_multiple_.yml │ └── test_menu_run_printout_after_change_tab.yml ├── test_formatting │ ├── test_regress__format_menu_single_with_key_.yml │ ├── test_regress__format_menu_single_without_key_.yml │ ├── test_regress_tab_header_description_none.yml │ ├── test_regress_missing_tab_header_description.yml │ ├── test_regress__format_headers.yml │ ├── test_regress__format_menu_multiple_.yml │ └── test_many_tabs_with_long_headers.yml ├── test_tab │ ├── test_regress_create_tab_object_single_with_key_.yml │ ├── test_regress_create_tab_object_single_without_key_.yml │ └── test_regress_create_tab_object_multiple_.yml ├── data │ └── test_config.yaml ├── test_tab.py ├── test_formatting.py ├── conftest.py ├── test_normalizer.py ├── test_menu.py └── test_validators.py ├── docs ├── source │ ├── api.rst │ ├── changelog.rst │ ├── wishlist.rst │ ├── contents.rst │ ├── annotatedconfig.rst │ ├── index.rst │ ├── blanktemplates.rst │ ├── sampleerror.rst │ ├── conf2.py │ ├── conf.py │ ├── tutorial.rst │ └── exampleapp.rst ├── Makefile └── make.bat ├── src └── pytabby │ ├── _version.py │ ├── __init__.py │ ├── tab.py │ ├── formatting.py │ ├── normalizer.py │ ├── menu.py │ └── validators.py ├── example_app ├── tyler-nix-597157-unsplash.jpg ├── colton-duke-732468-unsplash.jpg ├── brandon-nelson-667507-unsplash.jpg ├── cade-roberts-769333-unsplash.jpg ├── prince-akachi-728006-unsplash.jpg ├── raj-eiamworakul-514562-unsplash.jpg └── app.py ├── requirements.txt ├── pyproject.toml ├── requirements-ci.txt ├── .coveragerc ├── prerelease_checklist.txt ├── example_configs ├── blank_template.yaml ├── blank_template.py ├── blank_template.json ├── tutorial.yaml ├── sampleerror.yaml ├── single_without_key_case_insensitive.yaml ├── annotated.yaml ├── single_with_key_case_sensitive.yaml └── multiple.yaml ├── CHANGELOG.rst ├── requirements-dev.txt ├── pylama.ini ├── .readthedocs.yml ├── LICENSE ├── .travis.yml ├── tox.ini ├── .gitignore ├── setup.py ├── appveyor.yml ├── README.rst ├── appveyor └── install.ps1 └── coverage.xml /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Blank init.py for namespace""" 2 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. autoclass:: pytabby.Menu 5 | -------------------------------------------------------------------------------- /tests/test_menu/test_with_valid_entry_single_with_key_.yml: -------------------------------------------------------------------------------- 1 | result: '1' 2 | -------------------------------------------------------------------------------- /tests/test_menu/test_with_valid_entry_single_without_key_.yml: -------------------------------------------------------------------------------- 1 | result: '1' 2 | -------------------------------------------------------------------------------- /tests/test_menu/test_with_valid_entry_multiple_.yml: -------------------------------------------------------------------------------- 1 | result: 2 | - un 3 | - '1' 4 | -------------------------------------------------------------------------------- /src/pytabby/_version.py: -------------------------------------------------------------------------------- 1 | """Master version for pytabby""" 2 | __version__ = "0.1.0" 3 | -------------------------------------------------------------------------------- /tests/test_formatting/test_regress__format_menu_single_with_key_.yml: -------------------------------------------------------------------------------- 1 | message=None: 2 | - '' 3 | - '[1] One' 4 | - '[2] 2' 5 | -------------------------------------------------------------------------------- /tests/test_formatting/test_regress__format_menu_single_without_key_.yml: -------------------------------------------------------------------------------- 1 | message=None: 2 | - '' 3 | - '[1] One' 4 | - '[2] 2' 5 | -------------------------------------------------------------------------------- /example_app/tyler-nix-597157-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/tyler-nix-597157-unsplash.jpg -------------------------------------------------------------------------------- /tests/test_menu/test_method_collect_input_with_valid_input_single_with_key_.yml: -------------------------------------------------------------------------------- 1 | result: 2 | return_value: '1' 3 | type: return 4 | -------------------------------------------------------------------------------- /tests/test_menu/test_method_collect_input_with_valid_input_single_without_key_.yml: -------------------------------------------------------------------------------- 1 | result: 2 | return_value: '1' 3 | type: return 4 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. include:: ../../CHANGELOG.rst 5 | :start-after: inclusion-marker-top 6 | 7 | -------------------------------------------------------------------------------- /example_app/colton-duke-732468-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/colton-duke-732468-unsplash.jpg -------------------------------------------------------------------------------- /example_app/brandon-nelson-667507-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/brandon-nelson-667507-unsplash.jpg -------------------------------------------------------------------------------- /example_app/cade-roberts-769333-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/cade-roberts-769333-unsplash.jpg -------------------------------------------------------------------------------- /example_app/prince-akachi-728006-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/prince-akachi-728006-unsplash.jpg -------------------------------------------------------------------------------- /tests/test_formatting/test_regress_tab_header_description_none.yml: -------------------------------------------------------------------------------- 1 | data: 2 | - '[un|deux:has a description]' 3 | - ' == -----------------------' 4 | -------------------------------------------------------------------------------- /example_app/raj-eiamworakul-514562-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prooffreader/pytabby/HEAD/example_app/raj-eiamworakul-514562-unsplash.jpg -------------------------------------------------------------------------------- /tests/test_formatting/test_regress_missing_tab_header_description.yml: -------------------------------------------------------------------------------- 1 | data: 2 | - '[un|deux:has a description]' 3 | - ' == -----------------------' 4 | -------------------------------------------------------------------------------- /tests/test_formatting/test_regress__format_headers.yml: -------------------------------------------------------------------------------- 1 | data: 2 | - '[un:Description|deux:has a description]' 3 | - ' -------------- =======================' 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # these are also included in setup.py 2 | # pytabby should be installed as pip install . or, if developing, pip install -e . 3 | PyYAML>=5.1 4 | schema>=0.7.0 -------------------------------------------------------------------------------- /tests/test_menu/test_method_collect_input_with_valid_input_multiple_.yml: -------------------------------------------------------------------------------- 1 | result: 2 | return_value: '1' 3 | type: return 4 | result_multiple: 5 | new_number: 1 6 | type: change_tab 7 | -------------------------------------------------------------------------------- /docs/source/wishlist.rst: -------------------------------------------------------------------------------- 1 | Wish list 2 | ========= 3 | 4 | .. include:: ../../README.rst 5 | :start-after: inclusion-marker-start-wishlist 6 | :end-before: inclusion-marker-stop-wishlist 7 | 8 | -------------------------------------------------------------------------------- /tests/test_formatting/test_regress__format_menu_multiple_.yml: -------------------------------------------------------------------------------- 1 | message=None: 2 | - '' 3 | - '[un:Description|deux:has a description]' 4 | - ' -------------- =======================' 5 | - '[three] 3' 6 | - '[four ] 4' 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 118 3 | target_version = ['py37'] 4 | include = '\.pyi?$' 5 | exclude = 'blank_template.py' 6 | 7 | # pyproject.toml is used only for black right now, but hopefully one day PEP518 will be enforced -------------------------------------------------------------------------------- /docs/source/contents.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | 4 | tutorial.rst 5 | api.rst 6 | annotatedconfig.rst 7 | exampleapp.rst 8 | sampleerror.rst 9 | blanktemplates.rst 10 | changelog.rst 11 | wishlist.rst -------------------------------------------------------------------------------- /tests/test_menu/test_method_print_menu_single_with_key_.yml: -------------------------------------------------------------------------------- 1 | output_with_message: 2 | - '' 3 | - '[1] One' 4 | - '[2] 2' 5 | - This is a magic string and that's okay 6 | - '' 7 | output_without_message: 8 | - '' 9 | - '[1] One' 10 | - '[2] 2' 11 | - '' 12 | -------------------------------------------------------------------------------- /tests/test_menu/test_method_print_menu_single_without_key_.yml: -------------------------------------------------------------------------------- 1 | output_with_message: 2 | - '' 3 | - '[1] One' 4 | - '[2] 2' 5 | - This is a magic string and that's okay 6 | - '' 7 | output_without_message: 8 | - '' 9 | - '[1] One' 10 | - '[2] 2' 11 | - '' 12 | -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | codecov==2.0.16 2 | coverage==5.0.2 3 | eradicate==1.0 4 | pygments==2.6.1 5 | pylama==7.7.1 6 | pylint>=2.3.1 7 | pytest-cov==2.8.1 8 | pytest-ordering==0.6 9 | pytest-regressions==2.0.0 10 | pytest==5.4.1 11 | restructuredtext-lint==1.3.0 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = src/tabbedshellmenus 3 | 4 | [report] 5 | exclude_lines: 6 | pragma: no cover 7 | raise AssertionError 8 | ^\s+# 9 | if __name__ == .__main__.: 10 | omit = 11 | tests/* 12 | */keypress.py 13 | show_missing: True 14 | -------------------------------------------------------------------------------- /docs/source/annotatedconfig.rst: -------------------------------------------------------------------------------- 1 | Annotated config file 2 | ===================== 3 | 4 | Here is a YAML config file, annotated to explain all the keys/values/lists etc. 5 | 6 | .. literalinclude:: ../../example_configs/annotated.yaml 7 | :language: yaml 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/test_formatting/test_many_tabs_with_long_headers.yml: -------------------------------------------------------------------------------- 1 | data: 2 | - '[xabcdefghij:xklmnopqrstuvwxyz|abcdefghijx:klmnopqrstuvwxyzx' 3 | - ' ============================= -----------------------------' 4 | - '|xabcdefghij:xklmnopqrstuvwxyz]' 5 | - ' ------------------------------' 6 | -------------------------------------------------------------------------------- /src/pytabby/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Just puts menu.Menu into the top pytabby package namespace""" 5 | 6 | # pylama:ignore=W0611,E800 # because used for namespace 7 | 8 | from . import _version 9 | 10 | from . import menu 11 | from .menu import Menu 12 | 13 | __version__ = _version.__version__ 14 | -------------------------------------------------------------------------------- /prerelease_checklist.txt: -------------------------------------------------------------------------------- 1 | [ ] Update version in src/pytabby/_version.py4 2 | [ ] Update version in setup.py 3 | [ ] Add version to changelog 4 | [ ] black (bash runblack.sh) 5 | [ ] pylama (bash runpylama.sh) 6 | [ ] pytest 7 | [ ] tox 8 | [ ] Commit and push 9 | [ ] Pass Travis and Appveyor tests 10 | [ ] Tag 11 | [ ] Build wheel 12 | [ ] Send to PyPI tests 13 | [ ] Send to PyPI -------------------------------------------------------------------------------- /tests/test_tab/test_regress_create_tab_object_single_with_key_.yml: -------------------------------------------------------------------------------- 1 | 0: 2 | - ':HEADS:' 3 | - null 4 | - null 5 | - null 6 | - ':SELECTORS:' 7 | - ':INPUT2RESULT:' 8 | - 1 9 | - return 10 | - n/a 11 | - 1 12 | - ':INPUT2RESULT:' 13 | - 2 14 | - return 15 | - n/a 16 | - 2 17 | - ':INPUT2RESULT:' 18 | - ONE 19 | - return 20 | - n/a 21 | - 1 22 | - ':INPUT2RESULT:' 23 | - tWo 24 | - return 25 | - n/a 26 | - 2 27 | -------------------------------------------------------------------------------- /example_configs/blank_template.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: False 2 | screen_width: 80 3 | tabs: 4 | - tab_header_input: tabinput1 5 | tab_header_description: tabdescript1 6 | tab_header_long_description: tablongdescript1 7 | items: 8 | - item_choice_displayed: choice1 9 | item_description: descript1 10 | item_inputs: 11 | - input1 12 | item_returns: return1 13 | 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | .. inclusion-marker-top 6 | 7 | This document records all notable changes to `pytabby `_. 8 | This project adheres to `Semantic Versioning `_. 9 | 10 | `0.1.0`_ 11 | --------- 12 | 13 | * 2019-04-25: Initial release 14 | 15 | 16 | .. _`0.1.0`: https://github.com/Prooffreader/pytabby/master 17 | -------------------------------------------------------------------------------- /tests/test_tab/test_regress_create_tab_object_single_without_key_.yml: -------------------------------------------------------------------------------- 1 | 0: 2 | - ':HEADS:' 3 | - null 4 | - null 5 | - null 6 | - ':SELECTORS:' 7 | - ':INPUT2RESULT:' 8 | - '1' 9 | - return 10 | - n/a 11 | - '1' 12 | - ':INPUT2RESULT:' 13 | - '2' 14 | - return 15 | - n/a 16 | - '2' 17 | - ':INPUT2RESULT:' 18 | - ONE 19 | - return 20 | - n/a 21 | - '1' 22 | - ':INPUT2RESULT:' 23 | - tWo 24 | - return 25 | - n/a 26 | - '2' 27 | -------------------------------------------------------------------------------- /tests/test_menu/test_method_print_menu_multiple_.yml: -------------------------------------------------------------------------------- 1 | output_with_message: 2 | - '' 3 | - '[un:Description|deux:has a description]' 4 | - ' ============== -----------------------' 5 | - '[1] One' 6 | - '[2] 2' 7 | - This is a magic string and that's okay 8 | - '' 9 | output_without_message: 10 | - '' 11 | - '[un:Description|deux:has a description]' 12 | - ' ============== -----------------------' 13 | - '[1] One' 14 | - '[2] 2' 15 | - '' 16 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # pyenv must be installed in your main python and you must have run pyenv local 3.6.x 3.7.x 2 | autodoc>==0.5.0 3 | black>=19.3b0 4 | codecov>=2.0.15 5 | coverage>=5.0a4 6 | doc8>=0.8.0 7 | eradicate>=1.0 8 | pylama>=7.6.6 9 | pylint>=2.3.1 10 | pytest-cov>=2.6.1 11 | pytest-ordering>=0.6 12 | pytest-regressions>=1.0.5 13 | pytest>=4.4.0 14 | sphinx_rtd_theme>=0.4.3 15 | Sphinx>=2.0.0 16 | tox-pyenv>=1.1.0 17 | tox>=3.9.0 18 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | skip=*/.tox/*,*/.env/*,*blank_template*,*/keypress.py 3 | linters=mccabe,pep257,pydocstyle,pep8,pycodestyle,pyflakes,pylint 4 | ignore=C0103,C0330,D203,D213,D400,D401,D406,D407,D412,D413,D415,D416,E114,E117 5 | # C0330 because black 6 | 7 | [pylama:*/conftest.py] 8 | ignore=E1121 9 | 10 | [pylama:pycodestyle] 11 | ignore=E501,W503 12 | 13 | [pylama:pydocstyle] 14 | ignore=D415,D400,D213 15 | 16 | [pylama:pep8] 17 | ignore=E501,W503 18 | 19 | [pylama:pep257] 20 | ignore=D407,D400,D413,D213 21 | 22 | [pylama:pylint] 23 | disable = R,C,W 24 | -------------------------------------------------------------------------------- /example_configs/blank_template.py: -------------------------------------------------------------------------------- 1 | config = {'case_sensitive': False, 2 | 'screen_width': 80, 3 | 'tabs': [{'tab_header_input': 'tabinput1', 4 | 'tab_header_description': 'tabdescript1', 5 | 'tab_header_long_description': 'tablongdescript2', 6 | 'items': [{'item_choice_displayed': 'choice1', 7 | 'item_description': 'descript1', 8 | 'item_inputs': ['input1'], 9 | "item_returns": 'return1' } 10 | ]}] 11 | } -------------------------------------------------------------------------------- /example_configs/blank_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "case_sensitive": false, 3 | "screen_width": 80, 4 | "tabs": [ 5 | { 6 | "tab_header_input": "tabinput1", 7 | "tab_header_description": "tabdescript1", 8 | "tab_header_long_description": "tablongdescript1", 9 | "items": [ 10 | { 11 | "item_choice_displayed": "choice1", 12 | "item_description": "descript1", 13 | "item_inputs": [ 14 | "input1" 15 | ], 16 | "item_returns": "return1" 17 | } 18 | ] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | pytabby 2 | ================ 3 | 4 | .. include:: ../../README.rst 5 | :start-after: inclusion-marker-top-of-index 6 | :end-before: inclusion-marker-before-wishlist 7 | 8 | .. NOTE:: 9 | You can have two or more tabs, in which case you will see the tab headers you can switch between 10 | above the menu choices, or you can 11 | 12 | Contents 13 | ======== 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | tutorial.rst 19 | api.rst 20 | annotatedconfig.rst 21 | exampleapp.rst 22 | sampleerror.rst 23 | blanktemplates.rst 24 | changelog.rst 25 | wishlist.rst 26 | 27 | Indices and tables 28 | ================== 29 | 30 | * :ref:`genindex` 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | builder: html 12 | 13 | # Build documentation with MkDocs 14 | #mkdocs: 15 | # configuration: mkdocs.yml 16 | 17 | # Optionally build your docs in additional formats such as PDF and ePub 18 | formats: all 19 | 20 | # Optionally set the version of Python and requirements required to build your docs 21 | python: 22 | version: 3.7 23 | install: 24 | - requirements: requirements.txt 25 | - requirements: requirements-dev.txt 26 | - method: setuptools 27 | path: . 28 | system_packages: true 29 | 30 | 31 | build: 32 | image: latest -------------------------------------------------------------------------------- /docs/source/blanktemplates.rst: -------------------------------------------------------------------------------- 1 | Blank config templates 2 | ====================== 3 | 4 | For your convenience, here are blank config templates in .yaml, .json and .py (dict) formats. 5 | Add tabs and items as desired. 6 | 7 | Remember, having only one tab is the same as having no tabs 8 | (there's nothing else to switch to), so in that case the 'tabs' key is optional, you can just have 9 | the 'items' key in the top level. And if you do have a 'tabs' key with only one items, 10 | it can't have 'tab_header_*' keys, because they would be meaningless and the program schema validator thinks you 11 | made a mistake by leaving out the other tabs. 12 | 13 | .. literalinclude:: ../../example_configs/blank_template.yaml 14 | :language: yaml 15 | 16 | .. literalinclude:: ../../example_configs/blank_template.json 17 | :language: json 18 | 19 | .. literalinclude:: ../../example_configs/blank_template.py 20 | :language: python 21 | 22 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /example_configs/tutorial.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: False 2 | screen_width: 80 3 | tabs: 4 | - tab_header_input: a 5 | tab_header_description: Menu a 6 | items: 7 | - item_choice_displayed: 1 8 | item_description: First choice 9 | item_inputs: 10 | - 1 11 | item_returns: choice1 12 | - item_choice_displayed: 2 13 | item_description: Second choice 14 | item_inputs: 15 | - 2 16 | item_returns: choice2 17 | - tab_header_input: b 18 | tab_header_long_description: You have just selected Menu B 19 | items: 20 | - item_choice_displayed: x,y,x 21 | item_description: x or y or z 22 | item_inputs: 23 | - x 24 | - y 25 | - z 26 | item_returns: xyz 27 | - item_choice_displayed: q 28 | item_description: Quit 29 | item_inputs: 30 | - Q 31 | - qUiT 32 | - bye 33 | item_returns: quit 34 | -------------------------------------------------------------------------------- /tests/data/test_config.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: true 2 | screen_width: 80 3 | tabs: 4 | - 5 | tab_header_input: un 6 | tab_header_description: Description 7 | items: 8 | - item_choice_displayed: 1 9 | item_description: One 10 | item_inputs: 11 | - 1 12 | - ONE 13 | item_returns: 1 14 | - item_choice_displayed: 2 15 | item_description: 2 16 | item_inputs: 17 | - 2 18 | - tWo 19 | item_returns: 2 20 | - 21 | tab_header_input: deux 22 | tab_header_description: has a description 23 | tab_header_long_description: This one has a long description, the other one does not 24 | items: 25 | - item_choice_displayed: three 26 | item_description: 3 27 | item_inputs: 28 | - 3 29 | - three 30 | item_returns: three 31 | - item_choice_displayed: four 32 | item_description: 4 33 | item_inputs: 34 | - 4 35 | - four 36 | item_returns: four! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Taylor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_tab/test_regress_create_tab_object_multiple_.yml: -------------------------------------------------------------------------------- 1 | 0: 2 | - ':HEADS:' 3 | - un 4 | - Description 5 | - null 6 | - ':SELECTORS:' 7 | - un 8 | - deux 9 | - ':INPUT2RESULT:' 10 | - 1 11 | - return 12 | - n/a 13 | - 1 14 | - ':INPUT2RESULT:' 15 | - 2 16 | - return 17 | - n/a 18 | - 2 19 | - ':INPUT2RESULT:' 20 | - ONE 21 | - return 22 | - n/a 23 | - 1 24 | - ':INPUT2RESULT:' 25 | - deux 26 | - change_tab 27 | - 1 28 | - n/a 29 | - ':INPUT2RESULT:' 30 | - tWo 31 | - return 32 | - n/a 33 | - 2 34 | - ':INPUT2RESULT:' 35 | - un 36 | - change_tab 37 | - 0 38 | - n/a 39 | 1: 40 | - ':HEADS:' 41 | - deux 42 | - has a description 43 | - This one has a long description, the other one does not 44 | - ':SELECTORS:' 45 | - un 46 | - deux 47 | - ':INPUT2RESULT:' 48 | - 3 49 | - return 50 | - n/a 51 | - three 52 | - ':INPUT2RESULT:' 53 | - 4 54 | - return 55 | - n/a 56 | - four! 57 | - ':INPUT2RESULT:' 58 | - deux 59 | - change_tab 60 | - 1 61 | - n/a 62 | - ':INPUT2RESULT:' 63 | - four 64 | - return 65 | - n/a 66 | - four! 67 | - ':INPUT2RESULT:' 68 | - three 69 | - return 70 | - n/a 71 | - three 72 | - ':INPUT2RESULT:' 73 | - un 74 | - change_tab 75 | - 0 76 | - n/a 77 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | branches: 4 | only: 5 | - master 6 | - stable 7 | matrix: 8 | include: 9 | - python: '3.7' 10 | env: 11 | - TRAVIS_TEST_NAME="Lint_only" 12 | install: 13 | - 'pip install --upgrade pip' 14 | - 'pip install .' 15 | - 'pip install -r requirements-ci.txt' 16 | script: 17 | - pylama src 18 | - pylama tests 19 | - pylama example_app 20 | - pylint src 21 | - pylint tests 22 | - pylint example_app 23 | - rst-lint *.rst 24 | - python: '3.6' 25 | env: 26 | - TRAVIS_TEST_NAME="Py36_nocodecov" 27 | install: 28 | - 'pip install --upgrade pip' 29 | - 'pip install .' 30 | - 'pip install -r requirements-ci.txt' 31 | script: pytest 32 | - python: '3.7' 33 | env: 34 | - TRAVIS_TEST_NAME="Py37_codecov" 35 | install: 36 | - 'pip install --upgrade pip' 37 | - 'pip install .' 38 | - 'pip install -r requirements-ci.txt' 39 | script: pytest 40 | after_success: 41 | - bash <(curl -s https://codecov.io/bash) 42 | -------------------------------------------------------------------------------- /example_configs/sampleerror.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: 'This should be a boolean' 2 | screen_width: 'This should be an integer' 3 | tabs: 4 | - # there is no tab_header_input for this tab 5 | # the other two keys missing is ok, they're optional 6 | items: 7 | - item_choice_displayed: choice1 8 | item_description: descript1 9 | item_inputs: 10 | - # item inputs is an empty list 11 | item_returns: return1 12 | - tab_header_input: a # this will conflict with an item input in another tab 13 | items: 14 | - item_choice_displayed: 1 15 | item_description: descript1 16 | item_inputs: 17 | - 1 # int is okay, but it will conflict with another input in the same list 18 | item_returns: None # must be coerceable to string, can't be None 19 | - item_choice_displayed: 2 20 | item_description: descript2 21 | item_inputs: 22 | - 1 # conflicts with previous item 23 | item_returns: 1 24 | - tab_header_input: b 25 | items: 26 | - item_choice_displayed: a 27 | item_description: descripta 28 | item_inputs: 29 | - a # conflicts with the input to switch to another tab 30 | item_returns: a 31 | - item_choice_displayed: foo 32 | item_description: bar 33 | item_inputs: 34 | - spam # conflicts with previous item 35 | # missing item_returns 36 | 37 | -------------------------------------------------------------------------------- /tests/test_menu/test_menu_run_printout_after_change_tab.yml: -------------------------------------------------------------------------------- 1 | after_change_None: 2 | - 'Change tab to deux: has a description' 3 | - This one has a long description, the other one does not 4 | - '' 5 | - '[un:Description|deux:has a description]' 6 | - ' -------------- =======================' 7 | - '[three] 3' 8 | - '[four ] 4' 9 | - '' 10 | after_change_dict: 11 | - 'Change tab to deux: has a description' 12 | - This one has a long description, the other one does not 13 | - '' 14 | - '[un:Description|deux:has a description]' 15 | - ' -------------- =======================' 16 | - '[three] 3' 17 | - '[four ] 4' 18 | - Message 2 19 | - '' 20 | after_change_string: 21 | - 'Change tab to deux: has a description' 22 | - This one has a long description, the other one does not 23 | - '' 24 | - '[un:Description|deux:has a description]' 25 | - ' -------------- =======================' 26 | - '[three] 3' 27 | - '[four ] 4' 28 | - Magic string but that's okay 29 | - '' 30 | before_change_None: 31 | - '' 32 | - '[un:Description|deux:has a description]' 33 | - ' -------------- =======================' 34 | - '[three] 3' 35 | - '[four ] 4' 36 | - '' 37 | before_change_dict: 38 | - '' 39 | - '[un:Description|deux:has a description]' 40 | - ' ============== -----------------------' 41 | - '[1] One' 42 | - '[2] 2' 43 | - Message 1 44 | - '' 45 | before_change_string: 46 | - '' 47 | - '[un:Description|deux:has a description]' 48 | - ' -------------- =======================' 49 | - '[three] 3' 50 | - '[four ] 4' 51 | - Magic string but that's okay 52 | - '' 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Note I'm running tox locally but not on travis and appveyor 2 | 3 | [tox] 4 | envlist = py36,py37 5 | tox_pyenv_fallback=False 6 | 7 | [testenv:py37] 8 | whitelist_externals = pylama 9 | deps = -rrequirements-ci.txt 10 | commands = pip install --upgrade pip 11 | pip install . 12 | pylama src 13 | pylama tests 14 | pylama example_app 15 | pytest 16 | 17 | [testenv:py36] 18 | deps = -rrequirements-ci.txt 19 | commands = pip install --upgrade pip 20 | pip install . 21 | pytest 22 | 23 | [pytest:py36] 24 | addopts=-v 25 | -s 26 | -ra 27 | --strict 28 | 29 | [pytest:py37] 30 | addopts=-v 31 | -s 32 | -ra 33 | --ff 34 | --showlocals 35 | --strict 36 | --cov-config=.coveragerc 37 | --cov=pytabby tests/ 38 | --cov-report term 39 | --cov-report html 40 | --cov-report xml 41 | --no-cov-on-fail 42 | 43 | # the following is for travis and local unless overruled by the avove 44 | [pytest] 45 | addopts=-v 46 | -s 47 | -ra 48 | --strict 49 | --cov-config=.coveragerc 50 | --cov=pytabby tests/ 51 | --cov-report xml 52 | --no-cov-on-fail 53 | --maxfail=1 54 | --showlocals 55 | # ^ necessary for local debugging 56 | 57 | markers = 58 | smoke 59 | function 60 | breaking 61 | regression 62 | integration 63 | use_fixtures 64 | 65 | testpaths=tests 66 | empty_parameter_set_mark = fail_at_collect 67 | filterwarnings = 68 | error 69 | ignore::DeprecationWarning 70 | norecursedirs = data 71 | -------------------------------------------------------------------------------- /tests/test_tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests pytabby/tab.py 5 | 6 | Note that tabs depend on having normalized config input. 7 | Regression tests only, based on input config 8 | If input config changes, regression tests will have to change. 9 | Functionality of this module is tested in menu.py tests 10 | """ 11 | 12 | # pylint: disable=C0116,C0330,W0212,C0103 13 | 14 | from copy import deepcopy 15 | 16 | import pytest 17 | 18 | import pytabby.normalizer as normalizer 19 | import pytabby.tab as tab 20 | 21 | 22 | def freeze_tab(tab_dict): 23 | """Makes a reproducible version of tab_dict for regression testing""" 24 | lst = [":HEADS:"] 25 | for k in ["head_choice", "head_desc", "head_desc_long"]: 26 | lst.append(tab_dict[k]) 27 | lst.append(":SELECTORS:") 28 | for item in tab_dict["selectors"]: 29 | lst.append(item) 30 | keys = sorted(tab_dict["input2result"].keys(), key=str) 31 | for k in keys: 32 | lst.append(":INPUT2RESULT:") 33 | lst.append(k) 34 | result = tab_dict["input2result"][k] 35 | lst.append(result["type"]) 36 | lst.append(result.get("new_number", "n/a")) 37 | lst.append(result.get("return_value", "n/a")) 38 | return lst 39 | 40 | 41 | @pytest.mark.regression 42 | @pytest.mark.run(order=3) 43 | def test_regress_create_tab_object(data_regression, config_all_with_id): 44 | """Only normalize single without key""" 45 | config, id_ = config_all_with_id 46 | c = deepcopy(config) 47 | if id_.find("without") != -1: 48 | c = normalizer.normalize(c) 49 | tabs = tab.create_tab_objects(c) 50 | data = {} 51 | for i, tab_instance in enumerate(tabs): 52 | data[i] = freeze_tab(tab_instance.__dict__) 53 | data_regression.check(data) 54 | -------------------------------------------------------------------------------- /example_configs/single_without_key_case_insensitive.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: False # optional, boolean, default False 2 | screen_width: 80 # optional, integer, default 80 3 | # Note since there is only one tab and no headers, there is no 'tab' key here unlike in the multiple and 4 | # single with key examples 5 | items: 6 | - item_choice_displayed: n # will be changed to str if not already 7 | item_description: Next directory 8 | item_inputs: 9 | - n # will be changed to str if not already 10 | item_returns: next_dir 11 | - item_choice_displayed: p 12 | item_description: Previous directory 13 | item_inputs: 14 | - p 15 | item_returns: prev_dir 16 | - item_choice_displayed: u 17 | item_description: Up a directory 18 | item_inputs: 19 | - u 20 | item_returns: up_dir 21 | - item_choice_displayed: e 22 | item_description: Enter directory (if one is current selection) # Note there is no way to 'silence' menu 23 | # Items as yet. On the wishlist! 24 | item_inputs: 25 | - e 26 | item_returns: enter_dir 27 | - item_choice_displayed: o 28 | item_description: Open file (if one is current selection) 29 | item_inputs: 30 | - o 31 | item_returns: open_file 32 | - item_choice_displayed: d 33 | item_description: Delete file (if one is current selection) 34 | item_inputs: 35 | - d 36 | item_returns: del_file 37 | - item_choice_displayed: X 38 | item_description: Delete directory and contents (if one is current selection) 39 | item_inputs: 40 | - X # will be lowercased by normalizer 41 | item_returns: del_dir 42 | - item_choice_displayed: Q # purposely made uppercase, will NOT be lowercased by normalizer 43 | item_description: Quit 44 | item_inputs: 45 | - Q # will be lowercased by normalizer 46 | item_returns: quit 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # custom for development 2 | .vscode/ 3 | runblack.sh 4 | lint.sh 5 | temp_*.py 6 | pip-wheel-metadata/ 7 | *.py,cover 8 | wishlist.txt 9 | codecov.sh 10 | prerelease_checklist.txt 11 | .python-version 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | #coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Environments 97 | .env 98 | .venv 99 | env/ 100 | venv/ 101 | ENV/ 102 | env.bak/ 103 | venv.bak/ 104 | 105 | # Spyder project settings 106 | .spyderproject 107 | .spyproject 108 | 109 | # Rope project settings 110 | .ropeproject 111 | 112 | # mkdocs documentation 113 | /site 114 | 115 | # mypy 116 | .mypy_cache/ 117 | -------------------------------------------------------------------------------- /docs/source/sampleerror.rst: -------------------------------------------------------------------------------- 1 | Sample InvalidSchemaError output 2 | ================================ 3 | 4 | To give you an idea of the different error messages (all output at once under one umbrella exception), 5 | let's use the following YAML with many problems, indicated by comments: 6 | 7 | .. literalinclude:: ../../example_configs/sampleerror.yaml 8 | :language: yaml 9 | 10 | And here's what happens when we try to initialize a Menu instance with that config: 11 | 12 | :: 13 | 14 | >>> from pytabby import Menu 15 | >>> config = Menu.safe_read_yaml('sampleerror.yaml') 16 | >>> menu = Menu(config) 17 | Traceback (most recent call last): 18 | File "", line 1, in 19 | File "<>pytabby/menu.py", line 58, in __init__ 20 | validators.validate_all(self._config) 21 | File "<>pytabby/validators.py", line 388, in validate_all 22 | raise InvalidInputError("\n".join(printed_message)) 23 | pytabby.validators.InvalidInputError: 24 | Errors: 25 | 1. schema.SchemaError: Key 'case_sensitive' error: 'This should be a boolean' should be instance of 'bool' 26 | 2. tab#0: schema.SchemaMissingKeyError: Missing key: 'tab_header_input' 27 | 3. tab#0,item#0,valid_entry#0: schema.SchemaError: (None) should evaluate to True 28 | 4. tab#2,item#1: schema.SchemaMissingKeyError: Missing key: 'item_returns' 29 | 5. In tab#1, there are repeated input values including tab selectors: [(1, 2)]. 30 | 6. In tab#2, there are repeated input values including tab selectors: [('a', 2)]. 31 | >>> 32 | 33 | As you can see, instead of having to fix the errors one by one, the error message output six errors to fix at once. 34 | 35 | It's a little unpythonic, but foolish consistency is the hobgoblin of bad design, to paraphrase. 36 | 37 | Now, it's possible certain kinds of errors can be 'swallowed' by others, so there's no guarantee that you won't have 38 | to do more than one round of fixes in particularly problematic configs, but this should save you much more than 39 | half your time. 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/source/conf2.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../src/')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'pytabby' 21 | copyright = '2019, David Taylor' 22 | author = 'David Taylor' 23 | version = '0.1.0' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '0.1.0' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.napoleon', 'sphinx.ext.autodoc', 'sphinx.ext.viewcode' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = 'sphinx_rtd_theme' 52 | 53 | html_style = '/default.css' 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = ['_static'] 59 | -------------------------------------------------------------------------------- /example_configs/annotated.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: False # optional, boolean, default False 2 | screen_width: 80 # optional, integer, default 80 3 | tabs: # if there's only one tab, there should be no headers. In fact, you can leave out the tabs 4 | # key entirely and have 'items' as a top-level key. 5 | - tab_header_input: a # this is the input that will change to this header; it can be any length 6 | tab_header_description: First tab # this key is optional, or it can be None. It is displayed next to the tab_header_input 7 | tab_header_long_description: Changing to First tab # this key is optional, or it can be set to None. 8 | # it is displayed only when changing tabs 9 | items: 10 | - item_choice_displayed: x # an element to be put within square brackets to the left of every choice/item line 11 | # note that it does not necessarily have to correspond at all to item_inputs, although 12 | # it probably should 13 | item_description: Choice x # displayed to the right of item_choice_displayed 14 | item_inputs: # not displayed, a list of all inputs that will trigger the return of this item's 'item_returns' 15 | - x 16 | item_returns: xmarksthespot # just a string 17 | - item_choice_displayed: y,z # note I put a comma here for clarity, but this field is not parsed, I could have put 18 | # anything eg "yz", "y|z", "y or z", "fizzbin", etc. 19 | item_description: Choice y or z 20 | item_inputs: 21 | - y 22 | - z 23 | item_returns: yorz 24 | - tab_header_input: bee # the second tab, note we can require a multi-letter input. This one has no descriptions. 25 | items: 26 | - item_choice_displayed: z # this overlaps with the other tab, but that's okay, it's a different tab 27 | item_description: Choice z 28 | item_inputs: 29 | - z 30 | item_returns: z 31 | - item_choice_displayed: spam # again multi-letter 32 | item_description: Surprise! 33 | item_inputs: 34 | - ham 35 | - jam 36 | - lamb # none of these match the choice displayed! Bad idea, but they don't have to! 37 | item_returns: 1001010001010 # why not? 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | """setup.py for pytabby""" 5 | 6 | 7 | 8 | 9 | import io 10 | import re 11 | from glob import glob 12 | from os.path import basename 13 | from os.path import dirname 14 | from os.path import join 15 | from os.path import splitext 16 | 17 | from setuptools import find_packages 18 | from setuptools import setup 19 | 20 | 21 | def read(*names, **kwargs): 22 | """Reads file""" 23 | return io.open( 24 | join(dirname(__file__), *names), 25 | encoding=kwargs.get('encoding', 'utf8') 26 | ).read() 27 | 28 | 29 | setup( 30 | name='pytabby', 31 | version='0.1.0', 32 | license='MIT', 33 | description='A simple, non-opinionated python terminal menu system WITH TABS', 34 | long_description='%s' % ( 35 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')) 36 | ), 37 | author='David Taylor', 38 | author_email='prooffreader@gmail.com', 39 | url='http://github.com/Prooffreader/pytabby', 40 | download_url = 'https://github.com/Prooffreader/pytabby/archive/v0.1.0.tar.gz', 41 | packages=find_packages('src'), 42 | package_dir={'': 'src'}, 43 | py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], 44 | include_package_data=True, 45 | zip_safe=False, 46 | classifiers=[ 47 | # complete classifier list: 48 | # http://pypi.python.org/pypi?%3Aaction=list_classifiers 49 | 'Development Status :: 4 - Beta', 50 | 'Intended Audience :: Developers', 51 | 'License :: OSI Approved :: MIT License', 52 | 'Operating System :: Unix', 53 | 'Operating System :: POSIX', 54 | 'Operating System :: Microsoft :: Windows', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.6', 58 | 'Programming Language :: Python :: 3.7', 59 | 'Programming Language :: Python :: Implementation :: CPython', 60 | 'Topic :: Software Development :: Libraries :: Python Modules', 61 | 'Topic :: Utilities', 62 | ], 63 | keywords=[ 64 | 'python', 'shell', 'terminal', 'console', 'tabs', 'tabbed', 'menu', 'menus' 65 | ], 66 | install_requires=[ 67 | 'PyYAML>=5.1', 'schema>=0.7.0' 68 | ], 69 | extras_require={ 70 | }, 71 | entry_points={ 72 | 'console_scripts': [ 73 | 'pytabby = pytabby.cli:main', 74 | ] 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('../../src/')) 16 | import pytabby 17 | 18 | import sphinx_rtd_theme 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'pytabby' 24 | copyright = '2019, David Taylor' 25 | author = 'David Taylor' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = pytabby.__version__ 29 | version = pytabby.__version__ 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = ['sphinx.ext.napoleon', 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.viewcode' 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ['build', '_build'] 49 | 50 | source_suffix = '.rst' 51 | 52 | #master_doc = 'index' 53 | 54 | language = None 55 | 56 | add_function_parentheses = True 57 | 58 | pygments_style = 'sphinx' 59 | 60 | todo_include_todos = False 61 | 62 | # -- Options for HTML output ------------------------------------------------- 63 | 64 | # The theme to use for HTML and HTML Help pages. See the documentation for 65 | # a list of builtin themes. 66 | # 67 | html_theme = 'sphinx_rtd_theme' 68 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 69 | 70 | # Add any paths that contain custom static files (such as style sheets) here, 71 | # relative to this directory. They are copied after the builtin static files, 72 | # so a file named "default.css" will overwrite the builtin "default.css". 73 | html_static_path = ['_static'] 74 | 75 | htmlhelp_basename = 'pytabby' 76 | -------------------------------------------------------------------------------- /src/pytabby/tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Helper functions for menu.Menu and Tab class to represent individual tabs in menu.Menu""" 5 | 6 | 7 | def create_tab_objects(config): 8 | """Creates Tab objects in list in order of (normalized) menu._config['tabs'] 9 | 10 | NOTE: tab_selectors is a list (in tab order) of 'header_input' values. 11 | It is needed because they are valid inputs along with the 'item_inputs' values of each tab 12 | For a single-tabbed (i.e. no-tabbed) layout, tab_selector == [] 13 | """ 14 | tab_selectors = [] 15 | for tab in config["tabs"]: 16 | if tab.get("tab_header_input", None): 17 | tab_selectors.append(tab["tab_header_input"]) 18 | tabs = [] 19 | for tab in config["tabs"]: 20 | tabs.append(Tab(tab, tab_selectors)) 21 | return tabs 22 | 23 | 24 | class Tab: 25 | """Tab class to represent individual tabs in Menu instance 26 | 27 | Methods: 28 | process_input: called from Menu instance, not user 29 | """ 30 | 31 | def __init__(self, tab_dict, tab_selectors): 32 | """Instantiator for Tab class instances. Called by Menu instance, not by user. 33 | 34 | Args: 35 | tab_dict (dict): passed from menu instantiator's _config 36 | tab_selectors (list): all values of 'header_input' in _config 37 | """ 38 | self.head_choice = tab_dict.get("tab_header_input", None) 39 | self.head_desc = tab_dict.get("tab_header_description", None) 40 | self.head_desc_long = tab_dict.get("tab_header_long_description", None) 41 | self.selectors = tab_selectors 42 | self._parse_items(tab_dict["items"]) 43 | 44 | def _parse_items(self, items): 45 | """Creates a dict of possible input values to possible return values""" 46 | self.input2result = {} 47 | for i, selector in enumerate(self.selectors): 48 | self.input2result[selector] = {"type": "change_tab", "new_number": i} 49 | for item in items: 50 | for entry in item["item_inputs"]: 51 | self.input2result[entry] = {"type": "return", "return_value": item["item_returns"]} 52 | 53 | def process_input(self, inputstr): 54 | """Processes input value from menu instance according to this Tab instance 55 | 56 | Args: 57 | inputstr (str): menu instance's input 58 | 59 | Returns: 60 | (dict), with "type" in ['change_tab', 'return' or 'invalid'] 61 | """ 62 | if inputstr in self.input2result.keys(): 63 | return self.input2result[inputstr] 64 | else: 65 | return {"type": "invalid"} 66 | -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | Everything depends on a good, valid config file. For this tutorial, we'll use the 5 | following short YAML (found in the Github repo as ``example_configs/tutorial.yaml)``: 6 | 7 | .. literalinclude:: ../../example_configs/tutorial.yaml 8 | :language: yaml 9 | 10 | This is a config for two tabs, each with two choices. 11 | 12 | First, import the ``Menu`` class: 13 | 14 | >>> from pytabby import Menu 15 | 16 | Then, you need a config file. There are two static methods you can call from the 17 | uninstantiated class, ``safe_read_yaml(path)`` and ``read_json`` path. Or you can 18 | write a python dict and pass that. Here, let's read the YAML file. 19 | 20 | >>> config = Menu.safe_read_yaml('tutorial.yaml') # the file shown above 21 | 22 | And now instantiate the class with the config dict: 23 | 24 | >>> menu = Menu(config) 25 | 26 | At this point, if your config has an invalid schema, the Menu class will raise an InvalidSchemaError 27 | and will output **ALL** the schema violations to stderr. (As opposed to raising just one, Then 28 | you fixing it, then raising another, you fixing it, etc.) 29 | 30 | If the menu instance is instantiated without errors, you can just run it! 31 | 32 | >>> menu.run() 33 | 34 | You'll see the following printed to stdout: 35 | 36 | :: 37 | 38 | [a:Menu a|b] 39 | ======== -- 40 | [1] First choice 41 | [2] Second choice 42 | ?: ▓ 43 | 44 | Note that the first tab, a, is underlined, showing that it's the active tab. Tab b has no description in the config, so it's very short. 45 | 46 | Now enter 'c' (an invalid choice) at the prompt. A new line appears (the program does not resend the entire meny to stdout): 47 | 48 | :: 49 | 50 | ?: c 51 | Invalid, try again: ▓ 52 | 53 | Now enter 'b' to switch to that tab. The following is sent to stdout: 54 | 55 | :: 56 | 57 | Invalid, try again: b 58 | Change tab to b 59 | You have just selected Menu B 60 | 61 | [a:Menu a|b] 62 | -------- == 63 | [x,y,x] x or y or z 64 | [q ] Quit 65 | ?: ▓ 66 | 67 | As you can see, the second tab, ``b``, is now underlined. (It really is more obvious if you use descriptions.) 68 | 69 | The program output ``Change tab to`` & the tab_header_input 70 | 71 | Since for this second tab, 'tab_long_description' was defined, that was printed as well (``You have just selected Menu B``, how boring). 72 | 73 | Now let's actually submit a valid choice (an invalid choice will give the same 'Invalid, try again: ' message as above). 74 | 75 | :: 76 | 77 | ?: x 78 | 79 | ('b', 'xyz') 80 | 81 | Now the menu has returned a value, a tuple of the tab input and the return value. The tuple is returned because different tabs could 82 | have the same return value. If there were only one tab, only the return value ``'xyz'`` would have been returned. 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /example_configs/single_with_key_case_sensitive.yaml: -------------------------------------------------------------------------------- 1 | case_sensitive: True # optional, boolean, default False 2 | screen_width: 80 # optional, integer, default 80 3 | # Note that there is only one tab and no header key/value pairs for that tab 4 | # This example will still work with its one unnecessary 'tab' key, with the 'items' key a value of 5 | # the 'tabs' key, **because there are no other values of the tabs key**, i.e. the 2-3 other keys -- 6 | # `tab_header_input`, (optional) `tab_header_description` or (optional) `tab_header_long_description`. 7 | # 8 | # The reason for this design choice is that if there is only one tab defined, the program will never show a 9 | # header/tab bar (since with one tab by definition you can't change tabs). However, if any of the 10 | # key/value pairs are present, instead of just the list of items, 11 | # it looks like the programmer wants multiple tabs, so it looks like having a single tab means s/he 12 | # accidentally forgot the other tabs, so the program won't accept it. 13 | # 14 | # I tried to come up with some plausible items for a single-menu Python shell app. The app I have in mind goes 15 | # through the filesystem file by file displaying the file and giving the user various options as to what to do 16 | # with the file or even the entire folder. This would be an inefficient way of handling files, to be sure! 17 | # It's only an example. 18 | tabs: 19 | - items: 20 | - item_choice_displayed: n 21 | item_description: Next directory 22 | item_inputs: 23 | - n # will be changed to str if not already 24 | item_returns: next_dir # will be changed to str if not already 25 | - item_choice_displayed: p 26 | item_description: Previous directory 27 | item_inputs: 28 | - p 29 | item_returns: prev_dir 30 | - item_choice_displayed: u 31 | item_description: Up a directory 32 | item_inputs: 33 | - u 34 | item_returns: up_dir 35 | - item_choice_displayed: e 36 | item_description: Enter directory (if one is current selection) # Note there is no way to 'silence' menu 37 | # Items as yet. On the wishlist! 38 | item_inputs: 39 | - e 40 | item_returns: enter_dir 41 | - item_choice_displayed: o 42 | item_description: Open file (if one is current selection) 43 | item_inputs: 44 | - o 45 | item_returns: open_file 46 | - item_choice_displayed: d 47 | item_description: Delete file (if one is current selection) 48 | item_inputs: 49 | - d 50 | item_returns: del_file 51 | - item_choice_displayed: D # uppercased same letter as previous item 52 | item_description: Delete directory and contents (if one is current selection) 53 | item_inputs: 54 | - D 55 | item_returns: del_dir 56 | - item_choice_displayed: QX # purposely made uppercase 57 | item_description: Quit 58 | item_inputs: 59 | - Q 60 | - X 61 | item_returns: quit 62 | 63 | -------------------------------------------------------------------------------- /tests/test_formatting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Note that all formatting functions require a config that has gone through normalizer.normalize() 5 | 6 | Only regression tests are used for these, because it's string output. 7 | Just don't forget to replace data files if input file changes. 8 | """ 9 | 10 | # pylama: ignore=D102 11 | # pylint: disable=C0116,C0330,W0212,C0103 12 | 13 | 14 | from copy import deepcopy 15 | 16 | import pytest 17 | 18 | import pytabby.formatting as formatting 19 | import pytabby.normalizer as normalizer 20 | 21 | 22 | @pytest.mark.regression 23 | @pytest.mark.run(order=2) 24 | def test_regress__format_headers(data_regression, config_multiple): 25 | """Only multiple-type schema has headers; no need to normalize""" 26 | tab_num = 1 # minimum non-first ordinal of a multiple tab layout 27 | c = deepcopy(config_multiple) 28 | result = formatting._format_headers(c["tabs"], tab_num, 80) 29 | data = {"data": result} 30 | data_regression.check(data) 31 | 32 | 33 | @pytest.mark.regression 34 | @pytest.mark.run(order=3) 35 | def test_regress__format_menu(data_regression, config_all_with_id): 36 | """Normalize first; call with different kinds of messages""" 37 | config, id_ = config_all_with_id 38 | c = deepcopy(config) 39 | data = {} 40 | if id_.find("multiple") != -1: 41 | tab_num = 1 42 | else: 43 | tab_num = 0 44 | c = normalizer.normalize(c) 45 | for message_type in ["string", "None"]: 46 | if message_type == "None": 47 | message = None 48 | elif message_type == "string": 49 | message = "This is a magic string, but it's okay" 50 | result = formatting.format_menu(c, tab_num, 80, message).split("\n") 51 | data["message={}".format(message)] = result 52 | data_regression.check(data) 53 | 54 | 55 | @pytest.mark.regression 56 | @pytest.mark.run(order=3) 57 | class TestEdgeCases: 58 | """Edge cases to get 100% coverage. Will use only one config each""" 59 | 60 | def test_regress_tab_header_description_none(self, data_regression, config_multiple): 61 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 62 | c = deepcopy(config_multiple) 63 | c["tabs"][0]["tab_header_description"] = None 64 | result = formatting._format_headers(c["tabs"], 0, 80) 65 | data = {"data": result} 66 | data_regression.check(data) 67 | 68 | def test_regress_missing_tab_header_description(self, data_regression, config_multiple): 69 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 70 | c = deepcopy(config_multiple) 71 | if "tab_header_description" in c["tabs"][0].keys(): 72 | del c["tabs"][0]["tab_header_description"] 73 | result = formatting._format_headers(c["tabs"], 0, 80) 74 | data = {"data": result} 75 | data_regression.check(data) 76 | 77 | def test_many_tabs_with_long_headers(self, data_regression, config_multiple): 78 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 79 | c = deepcopy(config_multiple) 80 | c["tabs"][0]["tab_header_input"] = "abcdefghij" 81 | c["tabs"][0]["tab_header_description"] = "klmnopqrstuvwxyz" 82 | c["tabs"][1]["tab_header_input"] = "abcdefghijx" 83 | c["tabs"][1]["tab_header_description"] = "klmnopqrstuvwxyzx" 84 | c["tabs"].append(c["tabs"][0]) 85 | c["tabs"][-1]["tab_header_input"] = "xabcdefghij" 86 | c["tabs"][-1]["tab_header_description"] = "xklmnopqrstuvwxyz" 87 | result = formatting._format_headers(c["tabs"], 0, 80) 88 | data = {"data": result} 89 | data_regression.check(data) 90 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Setup fixtures. 5 | 6 | NOTE: this script tests uses static method menu.Menu.safe_read_yaml() and function 7 | validators._determine_schema_type, but they are both tested in their respective sections. So Exceptions will be 8 | raised here before those tests run, redundantly, but they're in both places to ensure 100% coverage 9 | 10 | TODO: Fix this 11 | """ 12 | 13 | # pylama: ignore=D102 14 | # pylint: disable=C0116,W0212,C0103 15 | 16 | from copy import deepcopy 17 | import os 18 | 19 | import pytest 20 | 21 | from pytabby import Menu 22 | 23 | pytest_plugins = ("regressions",) 24 | 25 | 26 | def load_multiple_config_yaml(): 27 | """Retrieve config dict from tests/data/test_config.yaml 28 | 29 | Tests whether it is multiple 30 | """ 31 | path_to_here = os.path.realpath(__file__) 32 | this_dir = os.path.split(path_to_here)[0] 33 | config_path = os.path.join(this_dir, "data", "test_config.yaml") 34 | config = Menu.safe_read_yaml(config_path) 35 | if len(config["tabs"]) <= 1: 36 | raise AssertionError 37 | if not isinstance(config, dict): 38 | raise AssertionError 39 | return config 40 | 41 | 42 | def create_single_with_key(multiple_config): 43 | """Create single_with_key type from multiple config""" 44 | d = deepcopy(multiple_config) 45 | d["tabs"] = d["tabs"][:1] 46 | del d["tabs"][0]["tab_header_input"] 47 | for key in ["tab_header_description", "tab_header_long_description"]: 48 | try: 49 | del d["tabs"][0][key] 50 | except KeyError: 51 | pass 52 | return d 53 | 54 | 55 | def create_single_without_key(with_key): 56 | """Create single_without_key type from single_with_key config""" 57 | d = deepcopy(with_key) 58 | d["items"] = d["tabs"][0]["items"] 59 | del d["tabs"] 60 | return d 61 | 62 | 63 | @pytest.fixture(scope="function") 64 | def config_multiple(): 65 | """Return multiple config fixture.""" 66 | return load_multiple_config_yaml() 67 | 68 | 69 | @pytest.fixture(scope="function") 70 | def config_single_with_key(): 71 | """Return single with key config fixture.""" 72 | multiple = load_multiple_config_yaml() 73 | return create_single_with_key(multiple) 74 | 75 | 76 | @pytest.fixture(scope="function") 77 | def config_single_without_key(): 78 | """Return single without key config fixture""" 79 | multiple = load_multiple_config_yaml() 80 | with_key = create_single_with_key(multiple) 81 | return create_single_without_key(with_key) 82 | 83 | 84 | @pytest.fixture( 85 | scope="function", 86 | params=[ 87 | load_multiple_config_yaml(), 88 | create_single_with_key(load_multiple_config_yaml()), 89 | create_single_without_key(create_single_with_key(load_multiple_config_yaml())), 90 | ], 91 | ids=["multiple", "single_with_key", "single_without_key"], 92 | ) 93 | def config_all(request): 94 | """Return all three config types""" 95 | return request.param 96 | 97 | 98 | @pytest.fixture( 99 | scope="function", 100 | params=[ 101 | load_multiple_config_yaml(), 102 | create_single_with_key(load_multiple_config_yaml()), 103 | create_single_without_key(create_single_with_key(load_multiple_config_yaml())), 104 | ], 105 | ids=["multiple", "single_with_key", "single_without_key"], 106 | ) 107 | def config_all_with_id(request): 108 | """Return tuple of config dict, id for all config types""" 109 | return (request.param, ["multiple", "single_with_key", "single_without_key"][request.param_index]) 110 | 111 | 112 | @pytest.fixture(scope="function") 113 | def random_string(): 114 | """Stringified urandom bytes with alphanumerics only and alternating upper and lower case alphabeticals""" 115 | astr = str(os.urandom(10)) 116 | # capitalize every other alphabetic and remove non-alphanumeric 117 | n = 0 118 | new = [] 119 | for char in astr: 120 | if char.isalpha(): 121 | n += 1 122 | if n % 2 == 0: 123 | new.append(char.upper()) 124 | else: 125 | new.append(char.lower()) 126 | else: 127 | if char.isalnum(): 128 | new.append(char) 129 | new.extend("aBc") # just in case no alphas are included 130 | return "".join(new) 131 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # adapted from https://raw.githubusercontent.com/ogrisel/python-appveyor-demo/master/appveyor.yml 2 | # and then many things commented out 3 | 4 | image: Visual Studio 2017 5 | 6 | environment: 7 | # global: 8 | # Not sure if this is needed without C extension? 9 | # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the 10 | # /E:ON and /V:ON options are not enabled in the batch script intepreter 11 | # See: http://stackoverflow.com/a/13751649/163740 12 | # CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\appveyor\\run_with_env.cmd" 13 | 14 | matrix: 15 | 16 | 17 | - PYTHON: "C:\\Python37" 18 | PYTHON_VERSION: "3.7.x" # currently 2.7.9 19 | PYTHON_ARCH: "32" 20 | DEVREQUIREMENTS: "requirements-ci.txt" 21 | 22 | - PYTHON: "C:\\Python37-x64" 23 | PYTHON_VERSION: "3.7.x" # currently 2.7.9 24 | PYTHON_ARCH: "64" 25 | DEVREQUIREMENTS: "requirements-ci.txt" 26 | 27 | - PYTHON: "C:\\Python36" 28 | PYTHON_VERSION: "3.6.x" # currently 2.7.9 29 | PYTHON_ARCH: "32" 30 | DEVREQUIREMENTS: "requirements-ci.txt" 31 | 32 | - PYTHON: "C:\\Python36-x64" 33 | PYTHON_VERSION: "3.6.x" # currently 2.7.9 34 | PYTHON_ARCH: "64" 35 | DEVREQUIREMENTS: "requirements-ci.txt" 36 | 37 | install: 38 | # If there is a newer build queued for the same PR, cancel this one. 39 | # The AppVeyor 'rollout builds' option is supposed to serve the same 40 | # purpose but it is problematic because it tends to cancel builds pushed 41 | # directly to master instead of just PR builds (or the converse). 42 | # credits: JuliaLang developers. 43 | - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod ` 44 | https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | ` 45 | Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { ` 46 | throw "There are newer queued builds for this pull request, failing early." } 47 | - ECHO "Filesystem root:" 48 | - ps: "ls \"C:/\"" 49 | 50 | # Prepend Python to the PATH of this build (this cannot be 51 | # done from inside the powershell script as it would require to restart 52 | # the parent CMD process). 53 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" 54 | 55 | # Check that we have the expected version and architecture for Python 56 | - "python --version" 57 | - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" 58 | 59 | # Upgrade to the latest version of pip to avoid it displaying warnings 60 | # about it being out of date. 61 | - "python -m pip install --upgrade pip" 62 | 63 | # Install the build dependencies of the project. If some dependencies contain 64 | # compiled extensions and are not provided as pre-built wheel packages, 65 | # pip will build them from source using the MSVC compiler matching the 66 | # target Python version and architecture 67 | #- "%CMD_IN_ENV% pip install -r requirements.txt" 68 | - "%CMD_IN_ENV% pip install -r %DEVREQUIREMENTS%" 69 | 70 | build_script: 71 | # Build the compiled extension 72 | #- "%CMD_IN_ENV% python setup.py build" # TODO: this? If Windows wheel needed 73 | - "%CMD_IN_ENV% pip install ." 74 | 75 | test_script: 76 | # Run the project tests 77 | - ps: | 78 | &$env:PYTHON\python --version 79 | &$env:PYTHON\python -m pytest 80 | if ($LastExitCode -ne 0) { $host.SetShouldExit($LastExitCode) } 81 | 82 | # after_test: # TODO: This? Does this need a Windows whell, or is an 'any' wheel fine? 83 | # If tests are successful, create binary packages for the project. 84 | # - "%CMD_IN_ENV% python setup.py bdist_wheel" 85 | # - "%CMD_IN_ENV% python setup.py bdist_wininst" 86 | # - "%CMD_IN_ENV% python setup.py bdist_msi" 87 | # - ps: "ls dist" 88 | 89 | # artifacts: 90 | # Archive the generated packages in the ci.appveyor.com build report. 91 | # - path: dist\* 92 | 93 | #on_success: 94 | # - TODO: upload the content of dist/*.whl to a public wheelhouse 95 | 96 | # on_finish: 97 | # Upload test results to AppVeyor 98 | # - ps: | 99 | # this uploads nosetests.xml produced in test_script step 100 | # $wc = New-Object 'System.Net.WebClient' 101 | # $wc.UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\nosetests.xml)) 102 | -------------------------------------------------------------------------------- /src/pytabby/formatting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Contains functions used to format shell text output, i.e. multiline strings sent to stdout""" 5 | 6 | 7 | def format_menu(config, current_tab_number, line_length, message=None): 8 | """Creates menu to be displayed to user, called from menu.Menu only, not by user 9 | 10 | Args: 11 | config (dict): the config dict passed to the Menu instantiator, after normalization 12 | current_tab_number (int): number of currently selected tab (always 0 for single-tabbed menus) 13 | line_length (int): value from config 14 | message (str or None): a message to print from Menu.message 15 | 16 | Returns: 17 | (str) menu to send to stdout 18 | """ 19 | # get tabs list of dicts; since this is after normalization, this key will have been inserted 20 | # if it was missing 21 | tabs = config["tabs"] 22 | # build up a list of strings, one per line to send to stdout 23 | menu = [""] 24 | # only format headers if there are headers, i.e. if there is more than one tab 25 | if len(tabs) > 1: 26 | menu += _format_headers(tabs, current_tab_number, line_length) 27 | # get items from currently selected tab 28 | current_tab = tabs[current_tab_number] 29 | items = current_tab["items"] 30 | # find maximum length of item_choice_displayed in items to make sure they are equally justified 31 | max_choice_len = 0 32 | for item in items: 33 | max_choice_len = max(max_choice_len, len(item["item_choice_displayed"])) 34 | # build up one line per item, add to menu list 35 | for item in items: 36 | choice = item["item_choice_displayed"] 37 | description = item["item_description"] 38 | spacer = " " * (max_choice_len - len(choice)) 39 | menu.append("[{0}{1}] {2}".format(choice, spacer, description)) 40 | # add message if applicable 41 | if message is not None: 42 | menu.append(message) 43 | # return one string by concatenating lines of list 44 | return "\n".join(menu) 45 | 46 | 47 | def _format_headers(tabs, current_tab_number, line_length): 48 | """Formats just the tab portion if the config specifies a multi-tab menu 49 | 50 | Called from format_menu() 51 | 52 | Args: 53 | tabs (list of tab.Tab): list of Tab objects 54 | current_tab_number (int): number of currently selected tab (always 0 for single-tabbed menus) 55 | line_length (int): value from config 56 | 57 | Returns: 58 | (list of str) individual lines to be sent to stdout representing headers, and 59 | indicating the currently selected header 60 | """ 61 | current_line_length = 0 62 | # the text identifying all tabs 63 | top_text = [] 64 | # the text indicating which tab is currently selected 65 | bottom_text = [] 66 | # build the list of strings tab by tab 67 | for i, tab in enumerate(tabs): 68 | abbreviation = tab["tab_header_input"] 69 | description = tab.get("tab_header_description", None) 70 | if description is None: 71 | description = "" 72 | # use = for currently selected tab, - for other tabs 73 | if i == current_tab_number: 74 | bottom_char = "=" 75 | else: 76 | bottom_char = "-" 77 | # spacer is only required between abbreviation and description if there is a description 78 | if description: 79 | spacer = ":" 80 | else: 81 | spacer = "" 82 | # [ to start first tab, | between tabs and ] to end last tab 83 | if i == 0: 84 | start = "[" 85 | else: 86 | start = "|" 87 | if i == len(tabs) - 1: 88 | end = "]" 89 | else: 90 | end = "" 91 | new_top_entry = "{0}{1}{2}{3}{4}".format(start, abbreviation, spacer, description, end) 92 | # add a line return if the curent line with additional text would go over the maximum line length 93 | if current_line_length + len(new_top_entry) > line_length - 1: 94 | top_text.append("\n") 95 | bottom_text.append("\n") 96 | current_line_length = 0 97 | top_text.append(new_top_entry) 98 | # space below brackets or pipes in line above, - or = below text 99 | bottom_text.append(" " + bottom_char * (len(new_top_entry) - 1)) 100 | current_line_length += len(new_top_entry) 101 | # take lists with individual line-break members and turn into list with splits at the line breaks 102 | top_text = "".join(top_text).split("\n") 103 | bottom_text = "".join(bottom_text).split("\n") 104 | # add alternating top and bottom lines to multiline string to return 105 | total_text = [] 106 | for top, bottom in zip(top_text, bottom_text): 107 | total_text.append(top) 108 | total_text.append(bottom) 109 | return total_text 110 | -------------------------------------------------------------------------------- /docs/source/exampleapp.rst: -------------------------------------------------------------------------------- 1 | Example app 2 | =========== 3 | 4 | We're going to use ``pytabby`` to control program flow in an app that takes a directory full of files 5 | and allows you to categorize them into subdirectories. 6 | 7 | There are two menus: [d]irectory management, where we see if folders exist and create them if needed; 8 | and [f]ile management, where we assign files to subfolders. 9 | 10 | Our subfolders will be named ``interesting`` and ``boring``. 11 | 12 | Here's the python file, ``app.py``. (It can also be found in the project github repo under example_app/). Instead 13 | of dealing with an external YAML config file, I've just hardcoded the config as a dict into the python app: 14 | 15 | .. literalinclude:: ../../example_app/app.py 16 | :language: python 17 | 18 | You can try this out on any folder of files; in the GitHub repo, there's a folder called example_app with this 19 | script, ``app.py``, and six photos downloaded from `Unsplash `_, and resized to take up 20 | less space. Note that this program doesn't *show* the images, but feel free to build that capability into it! 21 | 22 | Here's an example terminal session using the above script: 23 | 24 | :: 25 | 26 | example_app$ ls 27 | app.py cade-roberts-769333-unsplash.jpg 28 | prince-akachi-728006-unsplash.jpg tyler-nix-597157-unsplash.jpg 29 | brandon-nelson-667507-unsplash.jpg colton-duke-732468-unsplash.jpg 30 | raj-eiamworakul-514562-unsplash.jpg 31 | 32 | There are six jpgs we will classify as interesting or boring, plus the app.py script that is smart enough to ignore itself when moving files. 33 | The ``boring`` and ``interesting`` folders are not yet present. 34 | 35 | :: 36 | 37 | example_app$ python app.py 38 | Enter directory (blank for current): 39 | 40 | [subdirs|files] 41 | ======= ------ 42 | [c] Create missing subdirectories 43 | [h] Help 44 | [q] Quit 45 | ?: c 46 | ./interesting/ CREATED 47 | ./boring/ CREATED 48 | 49 | If we try to create the directories again, we'll just be told they already exist 50 | 51 | :: 52 | 53 | [subdirs|files] 54 | ======= ------ 55 | [c] Create missing subdirectories 56 | [h] Help 57 | [q] Quit 58 | ?: c 59 | ./interesting/ EXISTS 60 | ./boring/ EXISTS 61 | 62 | 63 | [subdirs|files] 64 | ======= ------ 65 | [c] Create missing subdirectories 66 | [h] Help 67 | [q] Quit 68 | ?: h 69 | This app goes through the contents of a directory and allows you to 70 | categorize the files, either moving them to subdirectories called 71 | interesting/ and boring/ or skipping them. This functionality is 72 | handled by the second tab 73 | The first tab allows you to check if the subdirectories already 74 | exist, allows you to create them if they are missing, shows this help 75 | text and allows you to quit the app 76 | 77 | 78 | [subdirs|files] 79 | ======= ------ 80 | [c] Create missing subdirectories 81 | [h] Help 82 | [q] Quit 83 | ?: files 84 | Change tab to files 85 | 86 | [subdirs|files] 87 | ------- ====== 88 | [i] Move to interesting/ 89 | [b] Move to boring/ 90 | [s] Skip 91 | Current_file: 1 of 6: brandon-nelson-667507-unsplash.jpg 92 | ?: i 93 | ./brandon-nelson-667507-unsplash.jpg moved to ./interesting/ 94 | 95 | File moved to interesting 96 | 97 | [subdirs|files] 98 | ------- ====== 99 | [i] Move to interesting/ 100 | [b] Move to boring/ 101 | [s] Skip 102 | Current_file: 2 of 6: cade-roberts-769333-unsplash.jpg 103 | ?: b 104 | ./cade-roberts-769333-unsplash.jpg moved to ./boring/ 105 | 106 | File moved to boring 107 | 108 | [subdirs|files] 109 | ------- ====== 110 | [i] Move to interesting/ 111 | [b] Move to boring/ 112 | [s] Skip 113 | Current_file: 3 of 6: colton-duke-732468-unsplash.jpg 114 | ?: s 115 | 116 | [subdirs|files] 117 | ------- ====== 118 | [i] Move to interesting/ 119 | [b] Move to boring/ 120 | [s] Skip 121 | Current_file: 4 of 6: prince-akachi-728006-unsplash.jpg 122 | ?: i 123 | ./prince-akachi-728006-unsplash.jpg moved to ./interesting/ 124 | 125 | File moved to interesting 126 | 127 | [subdirs|files] 128 | ------- ====== 129 | [i] Move to interesting/ 130 | [b] Move to boring/ 131 | [s] Skip 132 | Current_file: 5 of 6: raj-eiamworakul-514562-unsplash.jpg 133 | ?: i 134 | ./raj-eiamworakul-514562-unsplash.jpg moved to ./interesting/ 135 | 136 | File moved to interesting 137 | 138 | [subdirs|files] 139 | ------- ====== 140 | [i] Move to interesting/ 141 | [b] Move to boring/ 142 | [s] Skip 143 | Current_file: 6 of 6: tyler-nix-597157-unsplash.jpg 144 | ?: i 145 | ./tyler-nix-597157-unsplash.jpg moved to ./interesting/ 146 | 147 | File moved to interesting 148 | All files done. 149 | 150 | Now the program exits, and we can verify all the files are where we expect 151 | 152 | :: 153 | 154 | example_app$ ls 155 | app.py boring colton-duke-732468-unsplash.jpg interesting 156 | example_app$ ls boring/ 157 | cade-roberts-769333-unsplash.jpg 158 | example_app$ ls interesting/ 159 | brandon-nelson-667507-unsplash.jpg prince-akachi-728006-unsplash.jpg 160 | raj-eiamworakul-514562-unsplash.jpg tyler-nix-597157-unsplash.jpg -------------------------------------------------------------------------------- /example_app/app.py: -------------------------------------------------------------------------------- 1 | """A simple app that shows some capabilities of pytabby.""" 2 | 3 | import glob 4 | import os 5 | import shutil 6 | 7 | from pytabby import Menu 8 | 9 | # to make this a self-contained app, hardcode the config dict 10 | 11 | CONFIG = { 12 | "case_sensitive": False, 13 | "screen_width": 80, 14 | "tabs": [{"tab_header_input": "subdirs", 15 | "items": [{"item_choice_displayed": "c", 16 | "item_description": "Create missing subdirectories", 17 | "item_inputs": ["c"], 18 | "item_returns": "create_subdirs"}, 19 | {"item_choice_displayed": "h", 20 | "item_description": "Help", 21 | "item_inputs": ["h"], 22 | "item_returns": "help"}, 23 | {"item_choice_displayed": "q", 24 | "item_description": "Quit", 25 | "item_inputs": ["q"], 26 | "item_returns": "quit"}]}, 27 | {"tab_header_input": "files", 28 | "items": [{"item_choice_displayed": "i", 29 | "item_description": "Move to interesting/", 30 | "item_inputs": ["i"], 31 | "item_returns": "interesting"}, 32 | {"item_choice_displayed": "b", 33 | "item_description": "Move to boring/", 34 | "item_inputs": ["b"], 35 | "item_returns": "boring"}, 36 | {"item_choice_displayed": "s", 37 | "item_description": "Skip", 38 | "item_inputs": ["s"], 39 | "item_returns": "skip"}]}]} 40 | 41 | 42 | def print_help(): 43 | """Print help string to stdout""" 44 | help_text = ( 45 | "This app goes through the contents of a directory and allows you to categorize the files, " 46 | "either moving them to subdirectories called interesting/ and boring/ or skipping them. This " 47 | "functionality is handled by the second tab\n The first tab allows you to check if the " 48 | "subdirectories already exist, allows you to create them if they are missing, shows this help " 49 | "text and allows you to quit the app\n" 50 | ) 51 | print(help_text) 52 | 53 | 54 | def get_directory(): 55 | """Get the name of a directory to use, or uses the current one""" 56 | valid = False 57 | while not valid: 58 | directory = input("Enter directory (blank for current): ") 59 | if directory.strip() == "": 60 | directory = os.getcwd() 61 | if os.path.isdir(directory): 62 | valid = True 63 | else: 64 | print("That directory does not exist.") 65 | return directory 66 | 67 | 68 | def get_files(): 69 | """Determine sorted list of files in the current working directory""" 70 | files = [] 71 | for item in glob.glob("./*"): 72 | # add current .py file in case it's in the directory 73 | if os.path.isfile(item) and os.path.split(item)[1] != os.path.split(__file__)[1]: 74 | files.append(item) 75 | return sorted(files) 76 | 77 | 78 | def create_subdirectories(): 79 | """Create subdirectories if they do not exist""" 80 | for subdir in ["interesting", "boring"]: 81 | if os.path.isdir(subdir): 82 | print("./{0}/ EXISTS".format(subdir)) 83 | else: 84 | os.mkdir(subdir) 85 | print("./{0}/ CREATED".format(subdir)) 86 | print("") 87 | 88 | 89 | def move_to_subdir(filename, subdirname): 90 | """Move filename to subdirname""" 91 | if os.path.isfile(os.path.join(subdirname, filename)): 92 | raise ValueError("File already exists in that subdirectory!") 93 | shutil.move(filename, subdirname) 94 | print("{0} moved to ./{1}/".format(filename, subdirname)) 95 | print("") 96 | 97 | def main_loop(): # noqa: C901 98 | """Contain all the logic for the app""" 99 | menu = Menu(CONFIG) 100 | files = get_files() 101 | current_position = 0 102 | quit_early = False 103 | files_exhausted = False 104 | while not (quit_early or files_exhausted): 105 | filename = files[current_position] 106 | files_message = "Current_file: {0} of {1}: {2}".format(current_position + 1, len(files), 107 | os.path.split(filename)[1]) 108 | # message will be shown only when we are on the files tab 109 | result = menu.run(message={'files': files_message}) 110 | if result == ("subdirs", "create_subdirs"): 111 | create_subdirectories() 112 | elif result == ("subdirs", "help"): 113 | print_help() 114 | elif result == ("subdirs", "quit"): 115 | quit_early = True 116 | elif result[0] == "files" and result[1] in ["interesting", "boring"]: 117 | if not os.path.isdir(result[1]): 118 | raise ValueError("Directory must be created first") 119 | move_to_subdir(files[current_position], result[1]) 120 | print('File moved to {}'.format(result[1])) 121 | current_position += 1 122 | files_exhausted = current_position >= len(files) 123 | elif result == ("files", "skip"): 124 | current_position += 1 125 | files_exhausted = current_position >= len(files) 126 | else: 127 | raise AssertionError("Unrecognized input, this should have been caught by Menu validator") 128 | if files_exhausted: 129 | print("All files done.") 130 | else: 131 | print("Program quit early.") 132 | 133 | 134 | if __name__ == "__main__": 135 | 136 | CWD = os.getcwd() 137 | os.chdir(get_directory()) 138 | main_loop() 139 | os.chdir(CWD) 140 | -------------------------------------------------------------------------------- /example_configs/multiple.yaml: -------------------------------------------------------------------------------- 1 | # Note for this multiple tab example, I just copied the familiar elements of a typical windowed application. 2 | # Needless to say, it is a little unlikely a Python shell program will have these particular choices. 3 | # I was just looking for something familiar and not tied down to one particular use case. 4 | case_sensitive: False # optional, boolean, default False 5 | screen_width: 80 # optional, integer, default 80 6 | tabs: 7 | - tab_header_input: fi # this is the input that will change to this header; two letters so it doesn't conflict with Edit tab's find; 8 | tab_header_description: File # this key is optional, or it can be None. It is displayed next to the tab_header_input 9 | tab_header_long_description: Changing to File tab # this key is optional, or it can be set to None. 10 | # it is displayed only when changing tabs 11 | items: 12 | - item_choice_displayed: o # an element to be put within square brackets to the left of every choice/item line 13 | # note that it does not necessarily have to correspond at all to item_inputs, although 14 | # it probably should 15 | item_description: Open # displayed to the right of item_choice_displayed 16 | item_inputs: # not displayed, a list of all inputs that will trigger the return of this item's 'item_returns' 17 | - o 18 | item_returns: open 19 | - item_choice_displayed: w,c # note I put a comma here for clarity, but this field is not parsed, I could have put 20 | # anything eg "wc", "w|c", "w or c", "double-u or cee", "fizzbin", etc. 21 | item_description: Close 22 | item_inputs: 23 | - w 24 | - C # purposely simulating it "accidentally" capitalized; because case sensitive = False, it will make no difference. 25 | item_returns: close 26 | - item_choice_displayed: s 27 | item_description: Save 28 | item_inputs: 29 | - s 30 | item_returns: save 31 | - item_choice_displayed: sa 32 | item_description: Save As 33 | item_inputs: 34 | - sa # Note this requires the user to give a TWO-letter response, not just one, in contrast to the 35 | # items above it 36 | item_returns: save_as # At this point, the outer scope calling this module would presumably have to ask for 37 | # more information via input() or other means, e.g. a new file name and/or path 38 | - item_choice_displayed: p 39 | item_description: Preferences 40 | item_inputs: 41 | - p 42 | item_returns: preferences 43 | - item_choice_displayed: q 44 | item_description: Quit 45 | item_inputs: 46 | - q 47 | - x # note that the last two choices were not specified in item_choice_displayed, that's perfectly all right 48 | - quit 49 | item_returns: quit 50 | - tab_header_input: e 51 | tab_header_description: Edit 52 | tab_header_long_description: You have just selected the edit menu 53 | items: 54 | - item_choice_displayed: z 55 | item_description: Undo 56 | item_inputs: 57 | - z 58 | item_returns: undo 59 | - item_choice_displayed: y 60 | item_description: Redo 61 | item_inputs: 62 | - y 63 | item_returns: redo 64 | - item_choice_displayed: x 65 | item_description: Cut 66 | item_inputs: 67 | - x 68 | item_returns: cut 69 | - item_choice_displayed: c 70 | item_description: Copy 71 | item_inputs: 72 | - C 73 | item_returns: copy 74 | - item_choice_displayed: v 75 | item_description: Paste 76 | item_inputs: 77 | - v 78 | item_returns: paste 79 | - item_choice_displayed: f 80 | item_description: Find 81 | item_inputs: 82 | - f 83 | item_returns: find 84 | - item_choice_displayed: g 85 | item_description: Find again 86 | item_inputs: 87 | - g 88 | item_returns: find_again 89 | - item_choice_displayed: r 90 | item_description: Replace 91 | item_inputs: 92 | - r 93 | item_returns: replace 94 | - tab_header_input: vw # Two letters so it doesn't conflict with edit's "v"/paste 95 | tab_header_description: View 96 | items: 97 | - item_choice_displayed: cat 98 | item_description: cat 99 | item_inputs: 100 | - cat # Note that this takes a THREE-character entry 101 | item_returns: cat 102 | - item_choice_displayed: head 103 | item_description: head 104 | item_inputs: 105 | - head # four characters 106 | item_returns: head 107 | - item_choice_displayed: tail 108 | item_description: tail 109 | item_inputs: 110 | - tail 111 | item_returns: tail 112 | - tab_header_input: h 113 | tab_header_description: Help 114 | items: 115 | - item_choice_displayed: d 116 | item_description: Documentation 117 | item_inputs: 118 | - d 119 | item_returns: docs 120 | - item_choice_displayed: a 121 | item_description: About 122 | item_inputs: 123 | - a 124 | item_returns: about 125 | - tab_header_input: 0 # I just tacked this on at the end; since this is an input, it will be converted to a string 126 | # by the package's normalizer 127 | tab_header_description: Integers 128 | items: 129 | - item_choice_displayed: 1 130 | item_description: 'Choice #1' # needs to be wrapped in quotes or the '#' will be interpreted as a comment 131 | item_inputs: 132 | - 1 # will be changed to string, because it's an input 133 | item_returns: 1 # will be changed to string, because it's an output 134 | - item_choice_displayed: 2 135 | item_description: 2 136 | item_inputs: 137 | - 2 138 | item_returns: 2 139 | - item_choice_displayed: 3 140 | item_description: 'Choice #3' 141 | item_inputs: 142 | - 3 143 | item_returns: 3 144 | -------------------------------------------------------------------------------- /src/pytabby/normalizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | """Functions that ensure incoming configs have the same general features AFTER validation. 6 | 7 | The validation check comes first so they user can fix problems as they see it, not after it looks after normalization 8 | 9 | Normalization consists of, for those parts of the config that require it: 10 | 1. Adding default values where the key is missing 11 | 2. Converting elements to string where appropriate 12 | 3. Converting elements to lower-case where appropriate if config's case_sensitive is False 13 | """ 14 | 15 | 16 | from copy import deepcopy 17 | 18 | 19 | def _add_tabs_key_if_needed(config): 20 | """Adds redundant 'tab' key if config describes a single tab with 'items' as a top-level key. 21 | 22 | Called from normalize() 23 | 24 | Args: 25 | config (dict): config dict from menu.Menu 26 | 27 | Returns: 28 | config (dict): modified so that 'items' key is a member of 'tabs': list if appropriate 29 | otherwise, unchanged config 30 | """ 31 | if "tabs" not in config.keys(): 32 | if "items" not in config.keys(): # sanity check 33 | raise AssertionError("There is something wrong with the test suite if this error is called") 34 | config["tabs"] = [{"items": deepcopy(config["items"])}] 35 | del config["items"] 36 | return config 37 | else: 38 | return config 39 | 40 | 41 | def _walk_stringize_and_case(config): # noqa: C901 42 | """Walks the various contents of config and: 43 | 44 | 1. Adds default values if missing 45 | 2. Converts to string if not None where appropriate 46 | 3. If case_sensitive is False, converts to lowercase where appropriate 47 | 48 | Called from normalize() 49 | 50 | Args: 51 | config (dict): config dict from menu.Menu after possibly being modified by _add_key_if_needed() 52 | 53 | Returns: 54 | config (dict): modified so that 'items' key is a member of 'tabs': list if appropriate 55 | otherwise, unchanged config 56 | """ 57 | new_config = {} 58 | old_config = config 59 | 60 | if old_config.get("case_sensitive", None): 61 | new_config["case_sensitive"] = old_config["case_sensitive"] 62 | else: 63 | new_config["case_sensitive"] = False 64 | 65 | if old_config.get("screen_width", None): 66 | new_config["screen_width"] = old_config["screen_width"] 67 | else: 68 | new_config["screen_width"] = 80 69 | 70 | def stringify_and_recase(element, change_case=False, none_allowed=False): 71 | """Changes to string and/or changes case where appropriate. 72 | 73 | Args: 74 | element (object): Python object (probably a string or an int) that is a value of config 75 | change_case (bool): whether to change case if and only if nonlocal variable 76 | new_config["case_sensitive"] is true 77 | none_allowed (bool): whether element is allowed to be None, in which case if it is None, 78 | None is returned 79 | """ 80 | # return None if element is None and that's allowed 81 | if none_allowed and element is None: 82 | return None 83 | # change to lowercase if appropriate for element and for config's case_sensitive boolean key 84 | if change_case and not new_config["case_sensitive"]: 85 | return str(element).lower() 86 | else: 87 | # return as-is, but as a string 88 | return str(element) 89 | 90 | # walk tree of config["tabs"], building a new config tree with modified values where appropriate 91 | new_config["tabs"] = [] 92 | for old_tab in old_config["tabs"]: 93 | new_tab = {} 94 | # Note by iterating over the keys and values present in old_tab, there is no assumption made 95 | # that any key except for 'items' will be present. Congruity of the presence and types of these 96 | # keys was already done by the validators module 97 | for tab_key, old_tab_value in old_tab.items(): 98 | if tab_key == "tab_header_input": 99 | # since this is an input, it should be lowercased if config is not case-sensitive 100 | new_tab[tab_key] = stringify_and_recase(old_tab_value, change_case=True) 101 | elif tab_key in ["tab_header_description", "tab_header_long_description"]: 102 | # these are allowed to be None (or missing, though that's not checked here), and since 103 | # they are only printed to stdout, they should not change case 104 | new_tab[tab_key] = stringify_and_recase(old_tab_value, change_case=False, none_allowed=True) 105 | # items is the only key this function assumes will be there; its presence was already validated 106 | # by the validators script 107 | new_tab["items"] = [] 108 | for old_item in old_tab["items"]: 109 | new_item = {} 110 | for item_key, old_item_value in old_item.items(): 111 | if item_key in ["item_choice_displayed", "item_description", "item_returns"]: 112 | # these keys are changed to string only 113 | # changing the returns value to a string was a design decision which could be 114 | # reversed in future, e.g. so a function could be returned 115 | new_item[item_key] = stringify_and_recase(old_item_value) 116 | else: 117 | # the only other possible key, already validated, is 'item_inputs 118 | new_entries = [] 119 | for old_entry in old_item["item_inputs"]: 120 | # since this is an input, it should be lowercased if config is not case sensitive 121 | new_entries.append(stringify_and_recase(old_entry, True)) 122 | new_item["item_inputs"] = new_entries 123 | new_tab["items"].append(new_item) 124 | new_config["tabs"].append(new_tab) 125 | return new_config 126 | 127 | 128 | def normalize(config): 129 | """Calls semiprivate functions above to normalize config dict 130 | 131 | This allows menu.Menu and tab.Tab to be simplified, as they don't have to account for allowed variations 132 | 133 | Called from menu.Menu only, not by user 134 | 135 | Args: 136 | config (dict): config data as passed to menu.Menu instantiator 137 | Returns: 138 | (dict): normalized/modified config dict 139 | """ 140 | config = _add_tabs_key_if_needed(config) 141 | config = _walk_stringize_and_case(config) 142 | return config 143 | -------------------------------------------------------------------------------- /tests/test_normalizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests pytabby.normalizer.py""" 5 | 6 | # pylama: ignore=D102 7 | # pylint: disable=C0116,C0330,W0212,C0103 8 | 9 | from copy import deepcopy 10 | 11 | import pytest 12 | 13 | import pytabby.normalizer as normalizer 14 | 15 | @pytest.mark.function 16 | @pytest.mark.run(order=1) 17 | def test__add_tabs_key_if_needed_multiple(config_all_with_id): 18 | """Tests function 19 | 20 | Function should not change a multiple or single_with_key schema type 21 | but should change single_without_key 22 | """ 23 | conf, id_ = config_all_with_id 24 | c = deepcopy(conf) 25 | cprime = normalizer._add_tabs_key_if_needed(deepcopy(c)) 26 | if id_.find("without") == -1: 27 | if c != cprime: 28 | raise AssertionError 29 | else: 30 | if c == cprime: 31 | raise AssertionError 32 | 33 | 34 | @pytest.mark.integration 35 | @pytest.mark.run(order=2) 36 | class TestCaseSensitiveOrInsensitive: 37 | """Performs tests that do not depend on case insensitivity""" 38 | 39 | def test_tab_header_input_str(self, config_multiple): 40 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 41 | c = deepcopy(config_multiple) 42 | c["tabs"][0]["tab_header_input"] = 50 43 | normal = normalizer.normalize(c) 44 | if normal["tabs"][0]["tab_header_input"] != "50": 45 | raise AssertionError 46 | 47 | def test_item_inputs_str(self, config_all_with_id): 48 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 49 | conf, id_ = config_all_with_id 50 | c = deepcopy(conf) 51 | if id_.find("without") == -1: 52 | c["tabs"][0]["items"][0]["item_inputs"].append(50) 53 | else: 54 | c["items"][0]["item_inputs"].append(50) 55 | normal = normalizer.normalize(c) 56 | for tab in normal["tabs"]: 57 | for item in tab["items"]: 58 | for entry in item["item_inputs"]: 59 | if not isinstance(entry, str): 60 | raise AssertionError 61 | 62 | def test_default_case_sensitive(self, config_all): 63 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 64 | c = deepcopy(config_all) 65 | if c.get("case_sensitive", None): 66 | del c["case_sensitive"] 67 | normal = normalizer.normalize(c) 68 | result = normal.get("case_sensitive", None) 69 | if result is None or result: 70 | raise AssertionError 71 | 72 | def test_default_screen_width(self, config_all): 73 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 74 | c = deepcopy(config_all) 75 | if c.get("screen_width", None): 76 | del c["screen_width"] 77 | normal = normalizer.normalize(c) 78 | result = normal.get("screen_width", None) 79 | if result != 80: 80 | raise AssertionError 81 | 82 | 83 | @pytest.mark.integration 84 | @pytest.mark.run(order=2) 85 | class TestCaseInsensitive: 86 | """Tests that changes are made only where appropriate""" 87 | 88 | def test_tab_header_input_changed(self, config_multiple, random_string): 89 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 90 | c = deepcopy(config_multiple) 91 | c["case_sensitive"] = False 92 | c["tabs"][0]["tab_header_input"] = random_string 93 | normal = normalizer.normalize(c) 94 | if ( 95 | normal["tabs"][0]["tab_header_input"] == random_string 96 | or normal["tabs"][0]["tab_header_input"] != random_string.lower() 97 | ): 98 | raise AssertionError 99 | 100 | def test_other_headers_str_but_unchanged(self, config_multiple, random_string): 101 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 102 | c = deepcopy(config_multiple) 103 | c["case_sensitive"] = False 104 | for key in ["tab_header_description", "tab_header_long_description"]: 105 | c["tabs"][0][key] = random_string 106 | normal = normalizer.normalize(c) 107 | if normal["tabs"][0][key] != random_string: 108 | raise AssertionError 109 | 110 | def test_other_headers_none_ok(self, config_multiple): 111 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 112 | c = deepcopy(config_multiple) 113 | c["case_sensitive"] = False 114 | for key in ["tab_header_description", "tab_header_long_description"]: 115 | c["tabs"][0][key] = None 116 | normal = normalizer.normalize(c) 117 | if normal["tabs"][0][key] is not None: 118 | raise AssertionError 119 | 120 | def test_other_headers_missing_ok(self, config_multiple): 121 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 122 | c = deepcopy(config_multiple) 123 | c["case_sensitive"] = False 124 | for key in ["tab_header_description", "tab_header_long_description"]: 125 | if key in c["tabs"][0].keys(): 126 | del c["tabs"][0][key] 127 | normal = normalizer.normalize(c) 128 | if key in normal["tabs"][0].keys(): 129 | raise AssertionError 130 | 131 | def test_choice_fields_str_but_unchanged(self, config_all_with_id, random_string): 132 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 133 | conf, id_ = config_all_with_id 134 | for key in ["item_choice_displayed", "item_description", "item_returns"]: 135 | c = deepcopy(conf) 136 | c["case_sensitive"] = False 137 | if id_.find("without") == -1: 138 | c["tabs"][0]["items"][0][key] = random_string 139 | else: 140 | c["items"][0][key] = random_string 141 | normal = normalizer.normalize(c) 142 | if normal["tabs"][0]["items"][0][key] != random_string: 143 | raise AssertionError 144 | 145 | def test_item_inputs_changed(self, config_all_with_id, random_string): 146 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 147 | conf, id_ = config_all_with_id 148 | c = deepcopy(conf) 149 | c["case_sensitive"] = False 150 | if id_.find("without") == -1: 151 | c["tabs"][0]["items"][0]["item_inputs"][0] = random_string 152 | else: 153 | c["items"][0]["item_inputs"][0] = random_string 154 | normal = normalizer.normalize(c) 155 | if ( 156 | normal["tabs"][0]["items"][0]["item_inputs"][0] == random_string 157 | or normal["tabs"][0]["items"][0]["item_inputs"][0] != random_string.lower() 158 | ): 159 | raise AssertionError 160 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytabby 2 | ================ 3 | 4 | .. inclusion-marker-top-of-index 5 | 6 | .. image:: https://secure.travis-ci.org/Prooffreader/pytabby.png 7 | :target: http://travis-ci.org/Prooffreader/pytabby 8 | 9 | .. image:: https://ci.appveyor.com/api/projects/status/preqq0h4peiad07a?svg=true 10 | :target: https://ci.appveyor.com/project/Prooffreader/pytabby 11 | 12 | .. image:: https://codecov.io/gh/Prooffreader/pytabby/branch/master/graph/badge.svg 13 | :target: https://codecov.io/gh/Prooffreader/pytabby 14 | 15 | .. image:: https://api.codacy.com/project/badge/Grade/dae598fbe5b04b0e90e9e2080bb68c11 16 | :target: https://www.codacy.com/app/Prooffreader/pytabby?utm_source=github.com&utm_medium=referral&utm_content=Prooffreader/pytabby&utm_campaign=Badge_Grade) 17 | 18 | .. image:: https://camo.githubusercontent.com/14a9abb7e83098f2949f26d2190e04fb1bd52c06/68747470733a2f2f626c61636b2e72656164746865646f63732e696f2f656e2f737461626c652f5f7374617469632f6c6963656e73652e737667 19 | :target: https://github.com/Prooffreader/pytabby/blob/master/LICENSE 20 | 21 | .. image:: https://camo.githubusercontent.com/28a51fe3a2c05048d8ca8ecd039d6b1619037326/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d626c61636b2d3030303030302e737667 22 | :target: https://github.com/ambv/black 23 | 24 | .. image:: https://img.shields.io/badge/python-3.6%7C3.7-blue.svg 25 | :target: https://www.python.org/ 26 | 27 | .. image:: https://img.shields.io/badge/platform-linux--64%7Cwin--32%7Cwin--64-lightgrey.svg 28 | :target: https://github.com/Prooffreader/pytabby 29 | 30 | .. image:: https://badge.fury.io/py/pytabby.svg 31 | :target: https://pypi.org/project/pytabby 32 | 33 | .. image:: https://readthedocs.org/projects/pytabby/badge/?version=latest 34 | :target: https://pytabby.readthedocs.io/en/latest/ 35 | 36 | .. image:: https://pyup.io/repos/github/Prooffreader/pytabby/shield.svg 37 | :target: https://pyup.io/repos/github/Prooffreader/pytabby/ 38 | :alt: Updates 39 | 40 | A *flexible, non-opinionated*, **tabbed** menu system to interactively control program flow for 41 | terminal-based programs. It's a class with one sole public method which runs in a ``while`` 42 | loop as you switch tabs (if you want tabs, that is; you're free not to have any) or if you 43 | enter invalid input, and then returns a string based on the value you selected that 44 | you can use to control the outer program flow. 45 | 46 | Of course, you can run the class itself in a ``while`` loop in the enclosing program, getting 47 | menu choice after menu choice returned as you navigate a program. 48 | 49 | `Blog post about why I did this. `_ 50 | 51 | 52 | Installation 53 | ------------ 54 | 55 | ``pip install pytabby`` 56 | 57 | Meow. 58 | 59 | 60 | Documentation 61 | ------------- 62 | 63 | On `readthedocs `_. 64 | 65 | 66 | Usage 67 | ----- 68 | 69 | .. code-block:: python 70 | 71 | from pytabby import Menu 72 | myconfig = Menu.safe_read_yaml('path/to/yaml') 73 | # or Menu.read_json() or just pass a dict in the next step 74 | mymenu = Menu(myconfig) 75 | result = mymenu.run() 76 | 77 | if result == 'result1': 78 | do_this_interesting_thing() 79 | elif result == 'result2': 80 | do_this_other_thing() 81 | # etc... 82 | 83 | 84 | See it in action! 85 | ----------------- 86 | 87 | .. image:: https://www.dtdata.io/shared/pytabby.gif 88 | 89 | FAQ 90 | --- 91 | 92 | ***Why did you make this?** 93 | Well, it was one of those typical GitHub/PyPI scenarios, I wanted a specific thing, 94 | so I made a specific thing and then I took >10X the time making it a project so that 95 | others can use the thing; maybe some people will find it useful, maybe not. 96 | I like running programs in the terminal, and this allowed me to put a bunch of 97 | utilities like duplicate file finders and bulk file renamers all under one 98 | umbrella. If you prefer GUIs, there are plenty of simple wrappers out there, 99 | 100 | **Why can't I return handlers?** 101 | Out of scope for this project at this time, but it's on the 102 | Wish List. For now, the Menu instance just returns strings 103 | which the outer closure can then use to control program flow, 104 | including defining handlers using control flow/if statement 105 | based on the string returned by Menu.run(). 106 | 107 | **Why are my return values coming in/out strings?** 108 | To keep things simple, all input and output (return) values are 109 | converted to string. So if you have 110 | ``config['tabs'][0]['items][0]['item_returns'] = 1``, 111 | the return value will be '1'. 112 | 113 | **Why do 'items' have both 'item_choice_displayed' and 'item_inputs' keys?** 114 | To keep things flexible, you don't have to display exactly 115 | what you'll accept as input. For example, you could display 116 | 'yes/no' as the suggested answers to a yes or no question, but 117 | actually accept ['y', 'n', 'yes', 'no'], etc. 118 | 119 | **I have 'case_sensitive' = False, but my return value is still uppercase.** 120 | ``case_sensitive`` only affects inputs, not outputs 121 | 122 | **What's up with passing a dict with the tab name as a message to Menu.run()?** 123 | The message might be different depending on the tab, and ``run()`` 124 | only exits when it returns a value when given a valid item input. 125 | It changes tabs in a loop, keeping that implementation detail 126 | abstracted away from the user, as is right. 127 | 128 | 129 | Dependencies 130 | ------------ 131 | 132 | * ``PyYAML>=5.1`` 133 | * ``schema>=0.7.0`` 134 | 135 | .. inclusion-marker-before-wishlist 136 | 137 | Wish List: 138 | ---------- 139 | 140 | .. inclusion-marker-start-wishlist 141 | 142 | * a way to dynamically silence ("grey out", if this were a GUI menu system) 143 | certain menu items, which may be desired during program flow, probably by 144 | passing a list of silenced tab names and return values 145 | * have an option to accept single keypresses instead of multiple keys and 146 | ENTER with the input() function, using ``msvcrt`` package in Windows 147 | or ``tty`` and ``termios`` in Mac/Linux. (This will make coverage platform- 148 | dependent, so it will have to be cumulative on travis and appveyor) 149 | * incorporate ansimarkup (https://pypi.org/project/ansimarkup/) -- is it 150 | cross compatible? Will it work with cmder.exe on windows? So the app 151 | could have really cool colored tabs!!! Would colorama work? 152 | * Add MacOS CI (Circle?) 153 | * Fix tests by (1) changing lazy regression tests to true tests, (2) relying 154 | less on ordering to be true unit tests, (3) monkeypatch inputs instead of 155 | that ugly menu.Menu._testing hacks 156 | 157 | .. inclusion-marker-stop-wishlist 158 | 159 | Here's a picture of the pytabby tabby! 160 | -------------------------------------- 161 | 162 | .. image:: https://www.dtdata.io/shared/pytabby-kitty.jpg 163 | 164 | Photo credit: `Erik-Jan Leusink via Unsplash `_ 165 | -------------------------------------------------------------------------------- /appveyor/install.ps1: -------------------------------------------------------------------------------- 1 | # Sample script to install Python and pip under Windows 2 | # Authors: Olivier Grisel, Jonathan Helmus, Kyle Kastner, and Alex Willmer 3 | # License: CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ 4 | 5 | $MINICONDA_URL = "https://repo.continuum.io/miniconda/" 6 | $BASE_URL = "https://www.python.org/ftp/python/" 7 | $GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" 8 | $GET_PIP_PATH = "C:\get-pip.py" 9 | 10 | $PYTHON_PRERELEASE_REGEX = @" 11 | (?x) 12 | (?\d+) 13 | \. 14 | (?\d+) 15 | \. 16 | (?\d+) 17 | (?[a-z]{1,2}\d+) 18 | "@ 19 | 20 | 21 | function Download ($filename, $url) { 22 | $webclient = New-Object System.Net.WebClient 23 | 24 | $basedir = $pwd.Path + "\" 25 | $filepath = $basedir + $filename 26 | if (Test-Path $filename) { 27 | Write-Output "Reusing" $filepath 28 | return $filepath 29 | } 30 | 31 | # Download and retry up to 3 times in case of network transient errors. 32 | Write-Output "Downloading" $filename "from" $url 33 | $retry_attempts = 2 34 | for ($i = 0; $i -lt $retry_attempts; $i++) { 35 | try { 36 | $webclient.DownloadFile($url, $filepath) 37 | break 38 | } 39 | Catch [Exception]{ 40 | Start-Sleep 1 41 | } 42 | } 43 | if (Test-Path $filepath) { 44 | Write-Output "File saved at" $filepath 45 | } else { 46 | # Retry once to get the error message if any at the last try 47 | $webclient.DownloadFile($url, $filepath) 48 | } 49 | return $filepath 50 | } 51 | 52 | 53 | function ParsePythonVersion ($python_version) { 54 | if ($python_version -match $PYTHON_PRERELEASE_REGEX) { 55 | return ([int]$matches.major, [int]$matches.minor, [int]$matches.micro, 56 | $matches.prerelease) 57 | } 58 | $version_obj = [version]$python_version 59 | return ($version_obj.major, $version_obj.minor, $version_obj.build, "") 60 | } 61 | 62 | 63 | function DownloadPython ($python_version, $platform_suffix) { 64 | $major, $minor, $micro, $prerelease = ParsePythonVersion $python_version 65 | 66 | if (($major -le 2 -and $micro -eq 0) ` 67 | -or ($major -eq 3 -and $minor -le 2 -and $micro -eq 0) ` 68 | ) { 69 | $dir = "$major.$minor" 70 | $python_version = "$major.$minor$prerelease" 71 | } else { 72 | $dir = "$major.$minor.$micro" 73 | } 74 | 75 | if ($prerelease) { 76 | if (($major -le 2) ` 77 | -or ($major -eq 3 -and $minor -eq 1) ` 78 | -or ($major -eq 3 -and $minor -eq 2) ` 79 | -or ($major -eq 3 -and $minor -eq 3) ` 80 | ) { 81 | $dir = "$dir/prev" 82 | } 83 | } 84 | 85 | if (($major -le 2) -or ($major -le 3 -and $minor -le 4)) { 86 | $ext = "msi" 87 | if ($platform_suffix) { 88 | $platform_suffix = ".$platform_suffix" 89 | } 90 | } else { 91 | $ext = "exe" 92 | if ($platform_suffix) { 93 | $platform_suffix = "-$platform_suffix" 94 | } 95 | } 96 | 97 | $filename = "python-$python_version$platform_suffix.$ext" 98 | $url = "$BASE_URL$dir/$filename" 99 | $filepath = Download $filename $url 100 | return $filepath 101 | } 102 | 103 | 104 | function InstallPython ($python_version, $architecture, $python_home) { 105 | Write-Output "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 106 | if (Test-Path $python_home) { 107 | Write-Output $python_home "already exists, skipping." 108 | return $false 109 | } 110 | if ($architecture -eq "32") { 111 | $platform_suffix = "" 112 | } else { 113 | $platform_suffix = "amd64" 114 | } 115 | $installer_path = DownloadPython $python_version $platform_suffix 116 | $installer_ext = [System.IO.Path]::GetExtension($installer_path) 117 | Write-Output "Installing $installer_path to $python_home" 118 | $install_log = $python_home + ".log" 119 | if ($installer_ext -eq '.msi') { 120 | InstallPythonMSI $installer_path $python_home $install_log 121 | } else { 122 | InstallPythonEXE $installer_path $python_home $install_log 123 | } 124 | if (Test-Path $python_home) { 125 | Write-Output "Python $python_version ($architecture) installation complete" 126 | } else { 127 | Write-Output "Failed to install Python in $python_home" 128 | Get-Content -Path $install_log 129 | Exit 1 130 | } 131 | } 132 | 133 | 134 | function InstallPythonEXE ($exepath, $python_home, $install_log) { 135 | $install_args = "/quiet InstallAllUsers=1 TargetDir=$python_home" 136 | RunCommand $exepath $install_args 137 | } 138 | 139 | 140 | function InstallPythonMSI ($msipath, $python_home, $install_log) { 141 | $install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home" 142 | $uninstall_args = "/qn /x $msipath" 143 | RunCommand "msiexec.exe" $install_args 144 | if (-not(Test-Path $python_home)) { 145 | Write-Output "Python seems to be installed else-where, reinstalling." 146 | RunCommand "msiexec.exe" $uninstall_args 147 | RunCommand "msiexec.exe" $install_args 148 | } 149 | } 150 | 151 | function RunCommand ($command, $command_args) { 152 | Write-Output $command $command_args 153 | Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru 154 | } 155 | 156 | 157 | function InstallPip ($python_home) { 158 | $pip_path = $python_home + "\Scripts\pip.exe" 159 | $python_path = $python_home + "\python.exe" 160 | if (-not(Test-Path $pip_path)) { 161 | Write-Output "Installing pip..." 162 | $webclient = New-Object System.Net.WebClient 163 | $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) 164 | Write-Output "Executing:" $python_path $GET_PIP_PATH 165 | & $python_path $GET_PIP_PATH 166 | } else { 167 | Write-Output "pip already installed." 168 | } 169 | } 170 | 171 | 172 | function DownloadMiniconda ($python_version, $platform_suffix) { 173 | if ($python_version -eq "3.4") { 174 | $filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe" 175 | } else { 176 | $filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe" 177 | } 178 | $url = $MINICONDA_URL + $filename 179 | $filepath = Download $filename $url 180 | return $filepath 181 | } 182 | 183 | 184 | function InstallMiniconda ($python_version, $architecture, $python_home) { 185 | Write-Output "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 186 | if (Test-Path $python_home) { 187 | Write-Output $python_home "already exists, skipping." 188 | return $false 189 | } 190 | if ($architecture -eq "32") { 191 | $platform_suffix = "x86" 192 | } else { 193 | $platform_suffix = "x86_64" 194 | } 195 | $filepath = DownloadMiniconda $python_version $platform_suffix 196 | Write-Output "Installing" $filepath "to" $python_home 197 | $install_log = $python_home + ".log" 198 | $args = "/S /D=$python_home" 199 | Write-Output $filepath $args 200 | Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru 201 | if (Test-Path $python_home) { 202 | Write-Output "Python $python_version ($architecture) installation complete" 203 | } else { 204 | Write-Output "Failed to install Python in $python_home" 205 | Get-Content -Path $install_log 206 | Exit 1 207 | } 208 | } 209 | 210 | 211 | function InstallMinicondaPip ($python_home) { 212 | $pip_path = $python_home + "\Scripts\pip.exe" 213 | $conda_path = $python_home + "\Scripts\conda.exe" 214 | if (-not(Test-Path $pip_path)) { 215 | Write-Output "Installing pip..." 216 | $args = "install --yes pip" 217 | Write-Output $conda_path $args 218 | Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru 219 | } else { 220 | Write-Output "pip already installed." 221 | } 222 | } 223 | 224 | function main () { 225 | InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON 226 | InstallPip $env:PYTHON 227 | } 228 | 229 | main -------------------------------------------------------------------------------- /src/pytabby/menu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Contains Menu class; this is the base imported class of this package""" 5 | 6 | 7 | import json 8 | 9 | import yaml 10 | 11 | from . import formatting, normalizer, tab, validators 12 | 13 | 14 | class Menu: 15 | """Base class to import to create a menu 16 | 17 | Args: 18 | config (dict): a nested dict, in a schema which will be validated, containing everything needed 19 | to instantiate the Menu class 20 | start_tab_number(int): default 0, the number of the tab to start at 21 | 22 | Methods: 23 | safe_read_yaml(path_to_yaml): static method to read a yaml file into a config dict 24 | read_json(path_to_json): static method to read a json file into a config dict 25 | run(message=None): Displays menu at currently selected tab, asks for user input and returns it as a string 26 | 27 | Examples: 28 | 29 | >>> config = Menu.safe_read_yaml('config.yaml') 30 | >>> menu = Menu(config, start_tab_number=0) 31 | >>> result = menu.run() 32 | >>> if result = "action name": 33 | >>> my_great_function() 34 | 35 | >>> # with a submenu 36 | >>> config = Menu.safe_read_yaml('config.yaml') 37 | >>> menu = Menu(config, start_tab_number=0) 38 | >>> result = menu.run() 39 | >>> if result = "further options": 40 | >>> submenu = Menu(submenu_config) 41 | >>> if submenu_result = "action name": 42 | >>> my_great_function() 43 | """ 44 | 45 | def __init__(self, config, start_tab_number=0): 46 | """Instantiator for Menu class. 47 | 48 | Args: 49 | config (dict): a nested dict, in a schema which will be validated, containing everything needed 50 | to instantiate the Menu class 51 | start_tab_number(int): default 0, the number of the tab to start at 52 | """ 53 | self._config = config 54 | self._set_testing() 55 | # validate config 56 | validators.validate_all(self._config) 57 | # normalize config 58 | self._config = normalizer.normalize(self._config) 59 | 60 | self._current_tab_number = start_tab_number 61 | self._screen_width = self._config.get("screen_width", 80) 62 | self._has_multiple_tabs = len(self._config["tabs"]) > 1 63 | # ensure start_tab_number is valid 64 | if not self._current_tab_number < len(self._config["tabs"]): 65 | raise AssertionError 66 | self._create_tab_objects() 67 | # this attribute is only used by the instance to change user input where required; 68 | # the config contents have already been altered by the normalizer module 69 | self._case_sensitive = config.get("case_sensitive", False) 70 | 71 | @staticmethod 72 | def safe_read_yaml(path_to_yaml): 73 | """Reads yaml file at specified path. 74 | 75 | Args: 76 | path_to_yaml (str or pathlib.Path): path to a yaml file following the config schema 77 | 78 | Returns: 79 | (dict) config to pass to Menu instantiator 80 | """ 81 | with open(path_to_yaml, "r") as f: 82 | dict_ = yaml.safe_load(f.read()) 83 | return dict_ 84 | 85 | @staticmethod 86 | def read_json(path_to_json): 87 | """Reads json file at specified path. 88 | 89 | Args: 90 | path_to_json (str or pathlib.Path): path to a json file following the config schema 91 | 92 | Returns: 93 | (dict) config to pass to Menu instantiator 94 | """ 95 | with open(path_to_json, "r") as f: 96 | dict_ = json.load(f) 97 | return dict_ 98 | 99 | def _set_testing(self): 100 | """Sets self._testing to False during normal operation. 101 | 102 | During testing, this can be monkeypatched to one of the following values: 103 | 1. 'collect_input' when testing that function 104 | 2. 'run_tab' when testing tab changing in run() 105 | 3. 'run_invalid' when testing run() with invalid input 106 | 4. 'message' when testing run() with messages 107 | """ 108 | self._testing = False 109 | 110 | def _create_tab_objects(self): 111 | """Calls function in tab module""" 112 | self._tabs = tab.create_tab_objects(self._config) 113 | 114 | def _change_tab(self, new_number): 115 | """Changes the active tab. Only called from Menu instance .run()""" 116 | # print message about new selection 117 | new_tab = self._tabs[new_number] 118 | # display a message, including description and long_description if present, 119 | # informing user about the tab change 120 | if new_tab.head_choice: # Should be redundant, because should only be called if 121 | # the config's layout is multiple tabs. 122 | msg = ["Change tab to {0}".format(new_tab.head_choice)] 123 | if new_tab.head_desc: 124 | msg.append(": {0}".format(new_tab.head_desc)) 125 | if new_tab.head_desc_long: 126 | msg.append("\n{0}".format(new_tab.head_desc_long)) 127 | print("".join(msg)) 128 | self._current_tab_number = new_number 129 | 130 | def _print_menu(self, message=None): 131 | """Prints formatted menu to stdout""" 132 | formatted = formatting.format_menu(self._config, self._current_tab_number, self._screen_width, message) 133 | print(formatted) 134 | 135 | def _collect_input(self): 136 | """Gets choice from user, repeating until a valid choice given 137 | 138 | Returns: 139 | (dict) containing info about input, e.g. whether it's a new tab or something that leads to 140 | an input_returns value 141 | """ 142 | # flag 143 | received_valid_input = False 144 | prompt = "?" 145 | if self._testing == "message": 146 | return prompt 147 | while not received_valid_input: 148 | selection = input("{0}: ".format(prompt)) # the 'input' built-in is monkeypatched for testing 149 | # change input to lower-case if config is not case sensitive 150 | if not self._case_sensitive: 151 | selection = selection.lower() 152 | # call tab.Tab.process_input() function on current tab 153 | return_dict = self._tabs[self._current_tab_number].process_input(selection) 154 | if return_dict["type"] == "invalid": 155 | prompt = "Invalid, try again" 156 | else: 157 | received_valid_input = True 158 | if self._testing in ["run_invalid", "collect_input"]: # To avoid infinite loop in test 159 | return prompt 160 | return return_dict 161 | 162 | def _validate_message(self, message): 163 | """If run() is called with message as a dict, validates that all keys are valid tab_header_inputs. 164 | 165 | If message is None or string, does nothing. 166 | 167 | Raises: 168 | ValueError if keys do not match tab_header_inputs 169 | """ 170 | if isinstance(message, dict): 171 | if not self._has_multiple_tabs: 172 | raise ValueError("Menu instance has only one tab, so cannot take a dict as message arg for run()") 173 | nonmatching_keys = [] 174 | for key in message.keys(): 175 | if not any([x == key for x in self._tabs[0].selectors]): 176 | nonmatching_keys.append(key) 177 | if nonmatching_keys: 178 | raise ValueError( 179 | "The following key(s) in message dict do not match tab_header_inputs: {}".format( 180 | "; ".join(nonmatching_keys) 181 | ) 182 | ) 183 | else: 184 | if not isinstance(message, str) and message is not None: 185 | raise TypeError("message arg to run() must be None, str or dict") 186 | 187 | def _get_message(self, message): 188 | """Returns None or str or dict's value, as appropriate""" 189 | if not isinstance(message, dict): 190 | return message 191 | current_selector = self._tabs[0].selectors[self._current_tab_number] 192 | return message.get(current_selector, None) 193 | 194 | def run(self, message=None): 195 | """Called by user, runs menu until valid selection from a tab is made, and returns value 196 | 197 | Args: 198 | message (None, str or dict(str: str)): a message to display when the menu is shown. 199 | If it is a string, it will be shown at every iteration of the menu being shown 200 | until a valid item input is received and the function returns a value. 201 | If it is a dict, it should be key=tab_header_input and value=string message 202 | to be shown only when the current tab equals the key. There can be multiple 203 | key/value pairs. 204 | 205 | Returns: 206 | (str, str) or str: if there are multiple tabs, returns tuple of 207 | (tab_header_input, input_returns value). If there is only one tab, returns 208 | input_returns value only. 209 | """ 210 | self._validate_message(message) 211 | # flag 212 | received_return_value = False 213 | while not received_return_value: 214 | message_ = self._get_message(message) 215 | self._print_menu(message_) 216 | return_dict = self._collect_input() 217 | if self._testing in ["run_invalid", "message"]: 218 | return return_dict 219 | if return_dict["type"] == "change_tab": 220 | self._change_tab(return_dict["new_number"]) 221 | if self._testing == "run_tab": 222 | return return_dict 223 | else: 224 | if self._has_multiple_tabs: 225 | tab_id = self._tabs[self._current_tab_number].head_choice 226 | return (tab_id, return_dict["return_value"]) 227 | else: 228 | return return_dict["return_value"] 229 | -------------------------------------------------------------------------------- /tests/test_menu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests menu.py""" 5 | 6 | # pylama: ignore=D102 7 | # pylint: disable=C0116,C0330,W0212,C0103 8 | 9 | from copy import deepcopy 10 | import json 11 | import os 12 | 13 | import pytest 14 | 15 | # import from __init__ 16 | from pytabby import Menu 17 | import pytabby 18 | 19 | 20 | def yaml_path(): 21 | """Gets path to test yaml file""" 22 | path_to_here = os.path.realpath(__file__) 23 | this_dir = os.path.split(path_to_here)[0] 24 | return os.path.join(this_dir, "data", "test_config.yaml") 25 | 26 | 27 | @pytest.mark.smoke 28 | @pytest.mark.run(order=-1) 29 | def test_menu_smoke(config_all): 30 | """Smoke test to see if menu creation succeeds""" 31 | _ = Menu(config_all) 32 | 33 | 34 | @pytest.mark.integration 35 | @pytest.mark.run(order=5) 36 | class TestStaticMethods: 37 | """Tests the static methods to load data""" 38 | 39 | def test_yaml(self): 40 | """Loads test yaml and instantiates Menu""" 41 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 42 | config = Menu.safe_read_yaml(yaml_path()) 43 | _ = Menu(config) 44 | 45 | def test_json(self, tmpdir): 46 | """Loads test yaml, converts to json, loads json and instantiates Menu 47 | 48 | Also asserts the two dicts are equal 49 | """ 50 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 51 | config_from_yaml = Menu.safe_read_yaml(yaml_path()) 52 | p = tmpdir.mkdir("pytabbytest").join("temp.json") 53 | p.write(json.dumps(config_from_yaml)) 54 | config_from_json = Menu.read_json(str(p)) 55 | if not config_from_yaml == config_from_json: 56 | raise AssertionError 57 | 58 | 59 | @pytest.mark.function 60 | @pytest.mark.run(order=6) 61 | def test_method__change_tab(config_multiple, capsys, random_string): 62 | """Tests menu._change_tab""" 63 | c = deepcopy(config_multiple) 64 | c["tabs"][1]["tab_header_input"] = random_string[:3] 65 | c["tabs"][1]["tab_header_description"] = random_string[3:7] 66 | c["tabs"][1]["tab_header_long_description"] = random_string[7:] 67 | menu = Menu(c) 68 | if menu._current_tab_number != 0: 69 | raise AssertionError 70 | menu._change_tab(1) 71 | out, _ = capsys.readouterr() 72 | if menu._current_tab_number != 1: 73 | raise AssertionError 74 | for astr in [ 75 | "Change tab to {}".format(random_string[:3]), 76 | ": {}".format(random_string[3:7]), 77 | "\n{}".format(random_string[7:]), 78 | ]: 79 | if out.find(astr) == -1: 80 | raise AssertionError 81 | 82 | 83 | @pytest.mark.breaking 84 | @pytest.mark.run(order=7) 85 | def test_breaking_change_tab(config_single_with_key, config_single_without_key): 86 | """Should not work because you can't change tabs with single tabs""" 87 | for conf in (config_single_with_key, config_single_without_key): 88 | c = deepcopy(conf) 89 | menu = Menu(c) 90 | with pytest.raises(IndexError): 91 | menu._change_tab(1) 92 | 93 | 94 | @pytest.mark.regression 95 | @pytest.mark.run(order=8) 96 | def test_method_print_menu(config_all, capsys, data_regression): 97 | """Simple regression test of print output, with and without menu""" 98 | menu = Menu(config_all) 99 | data = {} 100 | menu._print_menu("This is a magic string and that's okay") 101 | out, _ = capsys.readouterr() 102 | data["output_with_message"] = out.split("\n") 103 | menu._print_menu() 104 | out, _ = capsys.readouterr() 105 | data["output_without_message"] = out.split("\n") 106 | if data["output_with_message"] == data["output_without_message"]: 107 | raise AssertionError("output without message should differ from output with message") 108 | data_regression.check(data) 109 | 110 | 111 | @pytest.mark.regression 112 | @pytest.mark.run(order=8) 113 | def test_menu_run_printout_after_change_tab(config_multiple, capsys, data_regression): 114 | """Simple regression test of print output, with different kinds of message for that tab""" 115 | menu = Menu(config_multiple) 116 | menu._testing = "message" 117 | data = {} 118 | tab_names = (config_multiple["tabs"][0]["tab_header_input"], config_multiple["tabs"][1]["tab_header_input"]) 119 | for message_type in ["dict", "string", "None"]: 120 | if message_type == "None": 121 | message = None 122 | elif message_type == "string": 123 | message = "Magic string but that's okay" 124 | elif message_type == "dict": 125 | message = {tab_names[0]: "Message 1", tab_names[1]: "Message 2"} 126 | menu.run(message) 127 | out_before_change, _ = capsys.readouterr() 128 | menu._change_tab(1) 129 | menu.run(message) 130 | out, _ = capsys.readouterr() 131 | # test that different tabs give different outputs 132 | if out_before_change == out: 133 | raise AssertionError 134 | data["before_change_" + message_type] = out_before_change.split("\n") 135 | data["after_change_" + message_type] = out.split("\n") 136 | if ( 137 | data["before_change_dict"] == data["before_change_string"] 138 | or data["before_change_dict"] == data["before_change_None"] 139 | or data["before_change_string"] == data["before_change_None"] 140 | or data["after_change_dict"] == data["after_change_string"] 141 | or data["after_change_dict"] == data["after_change_None"] 142 | or data["after_change_string"] == data["after_change_None"] 143 | ): 144 | raise AssertionError 145 | data_regression.check(data) 146 | 147 | 148 | @pytest.mark.regression 149 | @pytest.mark.run(order=8) 150 | class TestCollectInput: 151 | """will monkeypatch the module.input function""" 152 | 153 | def test_method_collect_input_with_valid_input(self, config_all_with_id, data_regression): 154 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 155 | conf, id_ = config_all_with_id 156 | c = deepcopy(conf) 157 | c["case_sensitive"] = False # to get 100% coverage 158 | menu = Menu(c) 159 | normal = menu._config 160 | test_input_valid_entry = normal["tabs"][0]["items"][0]["item_inputs"][0] 161 | data = {} 162 | pytabby.menu.input = lambda x: test_input_valid_entry 163 | result = menu._collect_input() 164 | data["result"] = result 165 | if id_.find("multiple") != -1: 166 | test_input_tab = normal["tabs"][1]["tab_header_input"] 167 | pytabby.menu.input = lambda x: test_input_tab 168 | result2 = menu._collect_input() 169 | data["result_multiple"] = result2 170 | data_regression.check(data) 171 | 172 | def teardown_method(self): 173 | """Reverts input""" 174 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 175 | pytabby.menu.input = input 176 | 177 | 178 | @pytest.mark.breaking 179 | @pytest.mark.run(order=9) 180 | class TestBreakingCollectInput: 181 | """Monkeypatches module.input function""" 182 | 183 | def test_break_collect_input(self, config_all, random_string): 184 | """Tries an invalid input with testing=True so it doesn't go into an infinite loop""" 185 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 186 | c = deepcopy(config_all) 187 | menu = Menu(c) 188 | # this assumes that random_string is not a valid entry in the config file 189 | # this is a pretty darn safe assumption 190 | pytabby.menu.input = lambda x: random_string 191 | menu._testing = "collect_input" 192 | result = menu._collect_input() 193 | if result != "Invalid, try again": 194 | raise AssertionError 195 | 196 | def teardown_method(self): 197 | """Reverts input""" 198 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 199 | pytabby.menu.input = input 200 | 201 | 202 | @pytest.mark.integration 203 | @pytest.mark.regression 204 | @pytest.mark.run(order=10) 205 | class TestRun: 206 | """Monkeypatches module.input function""" 207 | 208 | def test_with_invalid_input(self, config_all, random_string): 209 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 210 | c = deepcopy(config_all) 211 | menu = Menu(c) 212 | pytabby.menu.input = lambda x: random_string 213 | menu._testing = "run_invalid" 214 | result = menu.run() 215 | if result != "Invalid, try again": 216 | raise AssertionError 217 | 218 | def test_with_change_tab(self, config_multiple): 219 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 220 | c = deepcopy(config_multiple) 221 | menu = Menu(c) 222 | normal = menu._config 223 | test_input = normal["tabs"][1]["tab_header_input"] 224 | pytabby.menu.input = lambda x: test_input 225 | menu._testing = "run_tab" 226 | result = menu.run() 227 | if result != {"new_number": 1, "type": "change_tab"}: 228 | raise AssertionError 229 | 230 | def test_with_valid_entry(self, config_all, data_regression): 231 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 232 | c = deepcopy(config_all) 233 | menu = Menu(c) 234 | normal = menu._config 235 | test_input = normal["tabs"][0]["items"][0]["item_inputs"][0] 236 | pytabby.menu.input = lambda x: test_input 237 | data = {} 238 | result = menu.run() 239 | data["result"] = result 240 | data_regression.check(data) 241 | 242 | def test_fail_dict_message_on_single_tab(self, config_single_with_key): 243 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 244 | menu = Menu(config_single_with_key) 245 | with pytest.raises(ValueError): 246 | menu.run({"any_key": "any string"}) 247 | 248 | def test_fail_invalid_key_dict_message(self, config_multiple): 249 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 250 | menu = Menu(config_multiple) 251 | with pytest.raises(ValueError): 252 | menu.run({"nonexistent_key_magic_string": "any string"}) 253 | 254 | def test_fail_wong_type_message(self, config_multiple): 255 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 256 | menu = Menu(config_multiple) 257 | with pytest.raises(TypeError): 258 | menu.run({3}) 259 | 260 | def teardown_method(self): 261 | """Reverts input""" 262 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 263 | pytabby.menu.input = input 264 | -------------------------------------------------------------------------------- /coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /home/prooff/Projects/_archived/pytabby 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | -------------------------------------------------------------------------------- /src/pytabby/validators.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Functions to validate config dict as passed to menu.Menu instantiator 5 | 6 | Note that this module creates one exception listing all errors encountered. That way the user has the change to fix 7 | all errors, instead of having to deal with exception after exception one at a time 8 | 9 | Note on config_layout: 10 | There are three possible values of config_layout, determined from the config dict 11 | 1. 'multiple': a config with multiple tabs, therefore by necessity having an outermost 'tabs' key 12 | 2. 'single_with_key': a config with a single tab (i.e. with no tabs), but also containing a redundant 'tabs' key 13 | whose values is a list of size one containing a dict with the key 'items' 14 | 3. 'single_without_key': a config with a single tab but lacking the 'tabs' key; the single dict with the key 'items' 15 | is at the top level of the config dict, i.e. where 'tabs' would have been 16 | """ 17 | 18 | # pylint: disable=broad-except 19 | # allowing this ^ because it's validation... 20 | 21 | import re 22 | from collections import Counter 23 | 24 | from schema import And, Forbidden, Optional, Or, Schema 25 | from schema import ( 26 | SchemaError, 27 | SchemaForbiddenKeyError, 28 | SchemaMissingKeyError, 29 | SchemaUnexpectedTypeError, 30 | SchemaWrongKeyError, 31 | ) 32 | 33 | # SchemaOnlyOneAllowedError is not used 34 | 35 | SCHEMA_ERRORS = ( 36 | SchemaError, 37 | SchemaForbiddenKeyError, 38 | SchemaMissingKeyError, 39 | SchemaUnexpectedTypeError, 40 | SchemaWrongKeyError, 41 | ) 42 | 43 | 44 | class InvalidInputError(Exception): 45 | """Catchall exception for invalid input. 46 | 47 | Prints list of all errors to stderr. 48 | """ 49 | 50 | 51 | class _ValidSchemas: # pylint: disable=R0903 52 | 53 | """Data-holding class for Schema instances appropriate for different types of config. 54 | 55 | Instantiated from validate_schema() only 56 | """ 57 | 58 | def __init__(self): 59 | 60 | # outermost keys of config if it has a 'tabs' outermost key, i.e. if it is of layout 61 | # multiple or single_with_key 62 | self.outer_schema_multiple_or_single_with_key = Schema( 63 | { 64 | Optional("case_sensitive"): bool, 65 | Optional("screen_width"): And(int, lambda x: x > 0), 66 | "tabs": And(Or(list, tuple), lambda x: len(x) > 0), 67 | } 68 | ) 69 | 70 | # outermost keys for single_without_key layout 71 | self.outer_schema_single_without_key = Schema( 72 | { 73 | Optional("case_sensitive"): bool, 74 | Optional("screen_width"): And(int, lambda x: x > 0), 75 | "items": And(Or(list, tuple), lambda x: len(x) > 0), 76 | } 77 | ) 78 | 79 | # schema for each 'tab' value if there are multiple tabs 80 | self.tab_schema_multiple = Schema( 81 | { 82 | "tab_header_input": lambda x: x is not None and len(str(x)) > 0, 83 | Optional("tab_header_description"): lambda x: x is None or len(str(x)) > 0, 84 | Optional("tab_header_long_description"): lambda x: x is None or len(str(x)) > 0, 85 | "items": And(Or(list, tuple), lambda x: len(x) > 0), 86 | } 87 | ) 88 | 89 | # schema for the single redundant 'tab' value for the 'single_with_key' layout 90 | self.tab_schema_single_with_key = Schema( 91 | { 92 | Forbidden("tab_header_input"): object, 93 | Forbidden("tab_header_description"): object, 94 | Forbidden("tab_header_long_description"): object, 95 | "items": And(Or(list, tuple), lambda x: len(x) > 0), 96 | } 97 | ) 98 | 99 | # schema for items 100 | self.item_schema = Schema( 101 | { 102 | "item_choice_displayed": lambda x: x is not None and len(str(x)) > 0, 103 | Optional("item_description"): lambda x: x is None or len(str(x)) > 0, 104 | "item_inputs": And(Or(list, tuple), lambda x: len(x) > 0), 105 | "item_returns": lambda x: x is not None and len(str(x)) > 0, 106 | } 107 | ) 108 | 109 | # schema for each entry in 'item_inputs' 110 | self.entry_schema = Schema(lambda x: x is not None and len(str(x)) > 0) 111 | 112 | 113 | def _extract_class(class_repr): 114 | """Prettify class specifications in error messages 115 | 116 | Example: 117 | >>> _extract_class(") 118 | 'str' 119 | 120 | Args: 121 | class_repr (str): the output of an instance's .__class__ attribute 122 | 123 | Returns 124 | str, shorter 125 | """ 126 | return class_repr.replace("", "") 127 | 128 | 129 | def _validate_schema_part(error_messages, schema_, to_validate, prefix=None): 130 | """Validate that a section of the config follows schema 131 | 132 | Args: 133 | error_messages (list of str): list of all error messages produced by the validator to date 134 | schema_ (schema.Schema): instance defined in _ValidSchemas() class in this module 135 | to_validate (dict or str): config or subsection of config to validate 136 | prefix (str): prefix to be added to error message, to make it clear exactly where in the 137 | config dict the error occurred 138 | 139 | Returns: 140 | (list of str) error_messages, extended if applicable 141 | """ 142 | try: 143 | _ = schema_.validate(to_validate) 144 | except SCHEMA_ERRORS as e: # noqa 145 | error_type = _extract_class(str(e.__class__)) + ": " 146 | error_description = str(e).replace("\n", " ") 147 | if not prefix: 148 | prefix = "" 149 | error_message = prefix + error_type + error_description 150 | error_messages.append(error_message) 151 | return error_messages 152 | 153 | 154 | def _determine_config_layout(config): 155 | """Determine which of three valid schema types applies to input dict. 156 | 157 | Used in validate_schema() 158 | 159 | Valid Types are: 160 | 1. multiple tabs ('multiple') 161 | 2. single tab with tab key ('single_with_key') 162 | 3. single tab without tab key ('single_without_key') 163 | note that single tabs should have no header-related keys; this is checked in the Schema portion 164 | 165 | This function does not do a lot of error-checking, that is left to other functions. 166 | """ 167 | config_layout = None 168 | if "tabs" in config.keys(): 169 | if len(config["tabs"]) > 1: 170 | config_layout = "multiple" 171 | else: 172 | config_layout = "single_with_key" 173 | else: 174 | config_layout = "single_without_key" 175 | return config_layout 176 | 177 | 178 | def _validate_schema_multiple(error_messages, config, valid_schemas): 179 | """Validate type of schema, called by _validate_schema() (q.v.) if multiple type""" 180 | error_messages = _validate_schema_part( 181 | error_messages, valid_schemas.outer_schema_multiple_or_single_with_key, config 182 | ) 183 | try: 184 | level = "tabs" 185 | for tab_num, tab in enumerate(config["tabs"]): 186 | prefix = "tab#{0}: ".format(tab_num) 187 | error_messages = _validate_schema_part(error_messages, valid_schemas.tab_schema_multiple, tab, prefix) 188 | level = "items" 189 | for item_num, item in enumerate(tab["items"]): 190 | prefix = "tab#{0},item#{1}: ".format(tab_num, item_num) 191 | error_messages = _validate_schema_part(error_messages, valid_schemas.item_schema, item, prefix) 192 | level = "item_inputs" 193 | for entry_num, entry in enumerate(item["item_inputs"]): 194 | prefix = "tab#{0},item#{1},valid_entry#{2}: ".format(tab_num, item_num, entry_num) 195 | error_messages = _validate_schema_part(error_messages, valid_schemas.entry_schema, entry, prefix) 196 | except Exception as e: # noqa 197 | error_messages = _catch_iteration_error(error_messages, e, level) 198 | return error_messages 199 | 200 | 201 | def _validate_schema_single_with_key(error_messages, config, valid_schemas): 202 | """Validate type of schema, called by _validate_schema() (q.v.) if single_with_tab type""" 203 | error_messages = _validate_schema_part( 204 | error_messages, valid_schemas.outer_schema_multiple_or_single_with_key, config 205 | ) 206 | prefix = "sole tab: " 207 | try: 208 | level = "tabs" 209 | error_messages = _validate_schema_part( 210 | error_messages, valid_schemas.tab_schema_single_with_key, config["tabs"][0], prefix 211 | ) 212 | level = "items" 213 | for item_num, item in enumerate(config["tabs"][0]["items"]): 214 | prefix = "sole tab,item#{0}: ".format(item_num) 215 | error_messages = _validate_schema_part(error_messages, valid_schemas.item_schema, item, prefix) 216 | level = "item_inputs" 217 | for entry_num, entry in enumerate(item["item_inputs"]): 218 | prefix = "sole tab,item#{0},valid_entry#{1}: ".format(item_num, entry_num) 219 | error_messages = _validate_schema_part(error_messages, valid_schemas.entry_schema, entry, prefix) 220 | except Exception as e: # noqa 221 | error_messages = _catch_iteration_error(error_messages, e, level) 222 | return error_messages 223 | 224 | 225 | def _validate_schema_single_without_key(error_messages, config, valid_schemas): 226 | """Validate type of schema, called by _validate_schema() (q.v.) if single_without_tab type""" 227 | error_messages = _validate_schema_part(error_messages, valid_schemas.outer_schema_single_without_key, config) 228 | try: 229 | level = "items" 230 | for item_num, item in enumerate(config["items"]): 231 | prefix = "item#{0}: ".format(item_num) 232 | error_messages = _validate_schema_part(error_messages, valid_schemas.item_schema, item, prefix) 233 | level = "item_inputs" 234 | for entry_num, entry in enumerate(item["item_inputs"]): 235 | prefix = "item#{0},valid_entry#{1}: ".format(item_num, entry_num) 236 | error_messages = _validate_schema_part(error_messages, valid_schemas.entry_schema, entry, prefix) 237 | except Exception as e: # noqa 238 | error_messages = _catch_iteration_error(error_messages, e, level) 239 | return error_messages 240 | 241 | 242 | def _catch_iteration_error(error_messages, e, level): 243 | """Stop introspection on an iterable when it throws an exception, add it to error_messages""" 244 | error_type = _extract_class(str(e.__class__)) + ": " 245 | error_description = str(e).replace("\n", " ") 246 | error_message = "WHILE ITERATING OVER {0}: {1}{2}. No further introspection possible.".format( 247 | level, error_type, error_description 248 | ) 249 | error_messages.append(error_message) 250 | return error_messages 251 | 252 | 253 | def _validate_schema(error_messages, config): 254 | """Validate that config has the expected schema. 255 | 256 | Examples of valid schemas can be seen in the examples/ folder of the git repo, or in the docs. 257 | There are three kinds of schemas, one with multiple tabs, one with a single tab, and one with an implicit single 258 | tab that skips the redundant 'tabs' key 259 | 260 | Args: 261 | error_messages: list of str passed around 262 | config: the dict 263 | 264 | Returns: 265 | error messages list of str 266 | """ 267 | config_layout = _determine_config_layout(config) 268 | valid_schemas = _ValidSchemas() 269 | if config_layout == "multiple": 270 | error_messages = _validate_schema_multiple(error_messages, config, valid_schemas) 271 | elif config_layout == "single_with_key": 272 | error_messages = _validate_schema_single_with_key(error_messages, config, valid_schemas) 273 | elif config_layout == "single_without_key": 274 | error_messages = _validate_schema_single_without_key(error_messages, config, valid_schemas) 275 | else: 276 | raise AssertionError("unrecognized config_layout") 277 | return error_messages 278 | 279 | 280 | def _config_tabs(config): 281 | """Return 'tabs' list unless single_without_key, in which case manufacture one""" 282 | config_layout = _determine_config_layout(config) 283 | if config_layout == "single_without_key": 284 | return [{"items": config["items"]}] 285 | return config["tabs"] 286 | 287 | 288 | def _count_for_overlap(items_): 289 | """Count items, return multiple values only or empty set""" 290 | counter = Counter(items_) 291 | multiples = [x for x in counter.most_common() if x[1] > 1] 292 | return multiples 293 | 294 | 295 | def _validate_no_return_value_overlap(error_messages, config): 296 | """Validate that all return values in every tab are unique. 297 | 298 | Checks all tabs, so can result in long error message 299 | Case insensitivity does not affect return values 300 | """ 301 | tabs = _config_tabs(config) 302 | for tab_num, tab in enumerate(tabs): 303 | returns = [] 304 | if "items" in tab.keys(): # so as not to raise premature KeyError in invalid schema 305 | for item in tab["items"]: 306 | value = item.get("item_returns", None) 307 | if value: # so as not to raise premature KeyError in invalid schema 308 | returns.append(value) 309 | multiples = _count_for_overlap(returns) 310 | if multiples: 311 | if "tab_header_input" in tab.keys(): 312 | error_messages.append("In tab#{0}, there are repeated return values: {1}.".format(tab_num, multiples)) 313 | else: 314 | error_messages.append("In the single tab, there are repeated return values: {0}".format(multiples)) 315 | return error_messages 316 | 317 | 318 | def _validate_no_input_value_overlap(error_messages, config): # noqa:C901 319 | """Validate that the potential inputs on each tab are unambiguous. 320 | 321 | In other words, validates that any entry will either lead 322 | to another tab OR to returning a unique value OR the current tab's input value (this could have gone either 323 | way, I chose not to accept duplicate tab name and input in that tab for the sake of consistency rather than 324 | freeing up one possible input in a sort of weird edge case) 325 | 326 | Case insensitivity casts all inputs to lowercase, which can create overlap 327 | """ 328 | case_sensitive = config.get("case_sensitive", False) 329 | config_layout = _determine_config_layout(config) 330 | tabs = _config_tabs(config) 331 | starting_choices = [] 332 | # get tab header choices if multiple tabs 333 | if config_layout == "multiple": 334 | for tab in tabs: 335 | if tab.get("tab_header_input", None): # so as not to raise premature KeyError for invalid schema 336 | starting_choices.append(tab["tab_header_input"]) 337 | for tab_num, tab in enumerate(tabs): 338 | choices = starting_choices[:] 339 | if "items" in tab.keys(): # so as not to raise premature KeyError for invalid schema 340 | for item in tab["items"]: 341 | if "item_inputs" in item.keys(): # so as not to raise premature KeyError for invalid schema 342 | for entry in item["item_inputs"]: 343 | choices.append(entry) 344 | if not case_sensitive: 345 | choices = [str(choice).lower() for choice in choices] 346 | multiples = _count_for_overlap(choices) 347 | if multiples: 348 | if not case_sensitive: 349 | case_sensitive_message = ( 350 | " Note case sensitive is false, so values have been changed to lower-case, " 351 | "which can create overlap" 352 | ) 353 | else: 354 | case_sensitive_message = "" 355 | if config_layout == "multiple": 356 | error_messages.append( 357 | "In tab#{0}, there are repeated input values including tab selectors: {1}.{2}".format( 358 | tab_num, multiples, case_sensitive_message 359 | ) 360 | ) 361 | else: 362 | error_messages.append( 363 | "In single tab, there are repeated input values: {0}.{1}".format( 364 | multiples, case_sensitive_message 365 | ) 366 | ) 367 | return error_messages 368 | 369 | 370 | def _shorten_long_schema_error_messages(error_messages): 371 | """Remove entire config string from schema package error message.""" 372 | for i, message in enumerate(error_messages[:]): 373 | if re.search("in {.+}$", message): 374 | error_messages[i] = re.sub(r"in \{.+\}$", "in config", message) 375 | return error_messages 376 | 377 | 378 | def validate_all(config): 379 | """Run above non-underscored functions on input""" 380 | error_messages = [] 381 | error_messages = _validate_schema(error_messages, config) 382 | error_messages = _validate_no_input_value_overlap(error_messages, config) 383 | error_messages = _validate_no_return_value_overlap(error_messages, config) 384 | if error_messages: 385 | error_messages = _shorten_long_schema_error_messages(error_messages) 386 | printed_message = ["", "Errors:"] 387 | for i, message in enumerate(error_messages): 388 | printed_message.append("{0}. {1}".format(i + 1, message)) 389 | raise InvalidInputError("\n".join(printed_message)) 390 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests pytabby/validators.py""" 5 | 6 | # pylama: ignore=D102 7 | # pylint: disable=C0116,C0330,W0212,C0103 8 | 9 | from copy import deepcopy 10 | 11 | import pytest 12 | 13 | import pytabby.validators as validators 14 | 15 | # HELPER FUNCTIONS # 16 | 17 | 18 | def del_key_if_present(dict_, key): 19 | """Returns dict with deleted key if it was there, otherwise unchanged dict""" 20 | if key in dict_.keys(): 21 | del dict_[key] 22 | return dict_ 23 | 24 | 25 | # helper functions for breaking tests 26 | 27 | 28 | def validate_schema_fail(conf, expected_error_message_parts, case=None): 29 | """Boilerplate to validate the failure of a schema""" 30 | error_messages = [] 31 | error_messages = validators._validate_schema(error_messages, conf) 32 | if len(error_messages) != 1: 33 | raise AssertionError("There should be one error message, not {0}".format(len(error_messages))) 34 | for message in expected_error_message_parts: 35 | if error_messages[0].find(message) == -1: 36 | if case: 37 | msg = "CASE {0} MISSING IN ERROR MESSAGES: {1}".format(case, message) 38 | else: 39 | msg = "MISSING IN ERROR MESSAGES: {0}".format(message) 40 | raise AssertionError(msg) 41 | 42 | 43 | def validate_iteration_fail(conf, expected_error_message_part, case=None): 44 | """Boilerplate to validate the failure of iteration during a schema check 45 | 46 | Note there may be multiple errors, this will only check if the iteration error appears. 47 | """ 48 | error_messages = [] 49 | error_messages = validators._validate_schema(error_messages, conf) 50 | if validators._determine_config_layout(conf) is None: 51 | error_messages.append("Schema type not recognized") 52 | found = False 53 | for error_message in error_messages: 54 | if error_message.find(expected_error_message_part) != -1: 55 | found = True 56 | if not found: 57 | if case: 58 | msg = "CASE {0} MISSING IN ERROR MESSAGES: {1}".format(case, "; ".join(error_messages)) 59 | else: 60 | msg = "MISSING IN ERROR MESSAGES: {0}".format("; ".join(error_messages)) 61 | raise AssertionError(msg) 62 | 63 | 64 | # TESTS OF SINGLE FUNCTIONS 65 | 66 | 67 | @pytest.mark.function 68 | @pytest.mark.run(order=1) 69 | def test_fn__determine_config_layout(config_all_with_id): 70 | """Test that all config dicts return a valid type, and that it matches id""" 71 | config, id_ = config_all_with_id 72 | config_layout = validators._determine_config_layout(config) 73 | if config_layout not in ("multiple", "single_with_key", "single_without_key"): 74 | raise AssertionError("Unrecognized config_layout") 75 | if config_layout == "multiple": 76 | if id_.find("multiple") == -1: 77 | raise AssertionError("this id should include multiple") 78 | if config_layout.startswith("single"): 79 | if id_.find("single") == -1: 80 | raise AssertionError("this id should include single") 81 | if config_layout.endswith("_with_key"): 82 | if id_.find("with_key") == -1: 83 | raise AssertionError("this id should include with_key") 84 | if config_layout.endswith("_without_key"): 85 | if id_.find("without_key") == -1: 86 | raise AssertionError("this id should include with_key") 87 | 88 | 89 | @pytest.mark.smoke 90 | @pytest.mark.run(order=2) # order 2 because it depends on determine_config_layout(), and breaking tests depend on it 91 | def test_fn__validate_schema(config_all): 92 | """Test _validate_schema. Since it depends on _determine_config_layout, order=2""" 93 | error_messages = [] 94 | error_messages = validators._validate_schema(error_messages, config_all) 95 | if error_messages: 96 | raise AssertionError("config did not pass initial schema check") 97 | 98 | 99 | # TESTS CLASSES: TOP-LEVEL KEYS 100 | # NONBREAKING, THEN BREAKING 101 | @pytest.mark.function 102 | @pytest.mark.run(order=2) 103 | class TestSchemaTop: 104 | """Goes through all allowed values in the top level of configs""" 105 | 106 | def test_case_sensitive_missing(self, config_all): 107 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 108 | c = deepcopy(config_all) 109 | c = del_key_if_present(c, "case_sensitive") 110 | error_messages = [] 111 | error_messages = validators._validate_schema(error_messages, c) 112 | if error_messages: 113 | raise AssertionError(error_messages) 114 | 115 | def test_case_sensitive_bool(self, config_all): 116 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 117 | c = deepcopy(config_all) 118 | for case in [True, False]: 119 | c["case_sensitive"] = case 120 | error_messages = [] 121 | error_messages = validators._validate_schema(error_messages, c) 122 | if error_messages: 123 | raise AssertionError(case) 124 | 125 | def test_screen_width_missing(self, config_all): 126 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 127 | c = deepcopy(config_all) 128 | c = del_key_if_present(c, "screen_width") 129 | error_messages = [] 130 | error_messages = validators._validate_schema(error_messages, c) 131 | if error_messages: 132 | raise AssertionError(error_messages) 133 | 134 | def test_screen_width_int(self, config_all): 135 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 136 | c = deepcopy(config_all) 137 | c["screen_width"] = 40 138 | error_messages = [] 139 | error_messages = validators._validate_schema(error_messages, c) 140 | if error_messages: 141 | raise AssertionError(error_messages) 142 | 143 | def test_multiple_tabs(self, config_multiple): 144 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 145 | if not len(config_multiple["tabs"]) > 1: 146 | raise AssertionError # probably tautological but wth; actually might fail if test_config not 147 | 148 | 149 | @pytest.mark.breaking 150 | @pytest.mark.run(order=3) 151 | class TestBreakingSchemaTop: 152 | """Performs schema-invalidating changes on all config""" 153 | 154 | def test_wrong_type_case_sensitive(self, config_all): 155 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 156 | c = deepcopy(config_all) 157 | for case in ["string", -1, 2, 1.5, bool]: 158 | c["case_sensitive"] = case 159 | validate_schema_fail( 160 | c, ["schema.SchemaError: Key 'case_sensitive' error:", "should be instance of 'bool'"], case 161 | ) 162 | 163 | def test_wrong_type_invalid_screen_width(self, config_all): 164 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 165 | c = deepcopy(config_all) 166 | for case in ["string", 80.3, int]: 167 | c["screen_width"] = case 168 | validate_schema_fail( 169 | c, ["schema.SchemaError: Key 'screen_width' error:", "should be instance of 'int'"], case 170 | ) 171 | 172 | def test_lambda_invalid_screen_width(self, config_all): 173 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 174 | c = deepcopy(config_all) 175 | for case in [-1, 0]: 176 | c["screen_width"] = case 177 | validate_schema_fail( 178 | c, ["schema.SchemaError: Key 'screen_width' error:", "", case) 221 | 222 | def test_unexpected_top_level_key(self, config_all): 223 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 224 | c = deepcopy(config_all) 225 | c["astring"] = "astring" 226 | validate_schema_fail(c, ["schema.SchemaWrongKeyError: Wrong key"]) 227 | 228 | 229 | # TEST CLASSES: TABS 230 | # NONBREAKING, THEN BREAKING 231 | 232 | 233 | @pytest.mark.function 234 | @pytest.mark.run(order=2) 235 | class TestSchemaTabs: 236 | """Does non-breaking tests for schemas with tabs""" 237 | 238 | def test_tab_header_input_present(self, config_multiple): 239 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 240 | c = deepcopy(config_multiple) 241 | for tab in c["tabs"]: 242 | if not tab.get("tab_header_input", None): 243 | raise AssertionError 244 | 245 | def test_tab_header_descriptions_absent(self, config_multiple): 246 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 247 | for key in ["tab_header_description", "tab_header_long_description"]: 248 | c = deepcopy(config_multiple) 249 | c["tabs"][0] = del_key_if_present(c["tabs"][0], key) 250 | error_messages = [] 251 | error_messages = validators._validate_schema(error_messages, c) 252 | if error_messages: 253 | raise AssertionError(key) 254 | 255 | def test_tab_header_descriptions_values_incl_none(self, config_multiple): 256 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 257 | for key in ["tab_header_description", "tab_header_long_description"]: 258 | for case in [None, "astring", 1000, 2.5, KeyError]: # unlikely to pass a class, but it should work 259 | c = deepcopy(config_multiple) 260 | c["tabs"][0][key] = case 261 | error_messages = [] 262 | error_messages = validators._validate_schema(error_messages, c) 263 | if error_messages: 264 | raise AssertionError(key, case) 265 | 266 | def test_headers_absent_in_single(self, config_single_with_key): 267 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 268 | for key in ["tab_header_input", "tab_header_description", "tab_header_long_description"]: 269 | c = deepcopy(config_single_with_key) 270 | if c["tabs"][0].get(key, None): 271 | raise AssertionError(key) 272 | 273 | def test_items_present_and_iterable(self, config_multiple, config_single_with_key): 274 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 275 | for config in [config_multiple, config_single_with_key]: 276 | for tab in config["tabs"]: 277 | if not tab.get("items", None): 278 | raise AssertionError 279 | if not isinstance(tab["items"], list) and not isinstance(tab["items"], tuple): 280 | raise AssertionError 281 | 282 | 283 | @pytest.mark.breaking 284 | @pytest.mark.run(order=3) 285 | class TestBreakingSchemasTabs: 286 | """Tests tabs item in schemas to ensure they break appropriately""" 287 | 288 | def test_tab_header_input_absent(self, config_multiple): 289 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 290 | c = deepcopy(config_multiple) 291 | c["tabs"][0] = del_key_if_present(c["tabs"][0], "tab_header_input") 292 | validate_schema_fail(c, ["tab#0: schema.SchemaMissingKeyError: Missing key: 'tab_header_input'"]) 293 | 294 | def test_forbidden_headers_present(self, config_single_with_key): 295 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 296 | for key in ["tab_header_input", "tab_header_description", "tab_header_long_description"]: 297 | c = deepcopy(config_single_with_key) 298 | c["tabs"][0][key] = "astring" 299 | validate_schema_fail(c, ["Forbidden"]) 300 | 301 | def test_unexpected_headers_present(self, config_multiple, config_single_with_key): 302 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 303 | for config in [config_multiple, config_single_with_key]: 304 | c = deepcopy(config) 305 | c["tabs"][0]["astring"] = "astring" 306 | validate_schema_fail(c, ["schema.SchemaWrongKeyError: Wrong key"]) 307 | 308 | def test_headers_empty_string_len_0(self, config_multiple): 309 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 310 | c = deepcopy(config_multiple) 311 | for key in ["tab_header_input", "tab_header_description", "tab_header_long_description"]: 312 | c["tabs"][0][key] = "" 313 | validate_schema_fail(c, [""], key) 314 | 315 | def test_items_absent(self, config_multiple, config_single_with_key): 316 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 317 | for config in [config_multiple, config_single_with_key]: 318 | c = deepcopy(config) 319 | c["tabs"][0] = del_key_if_present(c["tabs"][0], "items") 320 | # iteration fail because can't iterate over items if it's not there 321 | validate_iteration_fail(c, "schema.SchemaMissingKeyError: Missing key:") 322 | 323 | def test_wrong_type_items_not_iterable(self, config_multiple, config_single_with_key): 324 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 325 | for config in [config_multiple, config_single_with_key]: 326 | c = deepcopy(config) 327 | for case in [{0, 1}, {0: 1}]: 328 | c["tabs"][0]["items"] = case 329 | validate_iteration_fail(c, "No further introspection possible", repr(case)) 330 | 331 | def test_wrong_type_items_string(self, config_multiple, config_single_with_key): 332 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 333 | for config in [config_multiple, config_single_with_key]: 334 | c = deepcopy(config) 335 | for case in ["string"]: 336 | c["tabs"][0]["items"] = case 337 | validate_iteration_fail( 338 | c, "TypeError: string indices must be integers. No further introspection possible.", case 339 | ) 340 | 341 | def test_wrong_type_items_other(self, config_multiple, config_single_with_key): 342 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 343 | for config in [config_multiple, config_single_with_key]: 344 | c = deepcopy(config) 345 | for case in [50, None]: 346 | c["tabs"][0]["items"] = case 347 | validate_iteration_fail(c, "No further introspection possible", case) 348 | 349 | 350 | # TEST CLASSES: ITEMS 351 | # NONBREAKING, THEN BREAKING 352 | 353 | 354 | @pytest.mark.function 355 | @pytest.mark.run(order=2) 356 | class TestSchemaItems: 357 | """Does non-breaking tests for items""" 358 | 359 | def test_mandatory_keys_present(self, config_all): 360 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 361 | c = deepcopy(config_all) 362 | config_layout = validators._determine_config_layout(config_all) 363 | if config_layout.find("without") != -1: 364 | items = c["items"] 365 | else: 366 | items = c["tabs"][0]["items"] 367 | for item in items: 368 | for key in ["item_choice_displayed", "item_inputs", "item_returns"]: 369 | if not item.get(key, None): 370 | raise AssertionError 371 | 372 | def test_several_types_3_keys(self, config_all): 373 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 374 | for key in ["item_choice_displayed", "item_description", "item_returns"]: 375 | for value in ["astring", 50, 2.57]: 376 | c = deepcopy(config_all) 377 | config_layout = validators._determine_config_layout(config_all) 378 | if config_layout.find("without") == -1: 379 | c["tabs"][0]["items"][0][key] = value 380 | else: 381 | c["items"][0][key] = value 382 | error_messages = [] 383 | error_messages = validators._validate_schema(error_messages, c) 384 | if error_messages: 385 | raise AssertionError 386 | 387 | def test_item_description_absent(self, config_all): 388 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 389 | c = deepcopy(config_all) 390 | config_layout = validators._determine_config_layout(config_all) 391 | if config_layout.find("without") == -1: 392 | c["tabs"][0]["items"][0] = del_key_if_present(c["tabs"][0]["items"][0], "item_description") 393 | else: 394 | c["items"][0] = del_key_if_present(c["items"][0], "item_description") 395 | error_messages = [] 396 | error_messages = validators._validate_schema(error_messages, c) 397 | if error_messages: 398 | raise AssertionError 399 | 400 | def test_item_description_values(self, config_all): 401 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 402 | for value in [None, ["astring"], ("string1", "string2")]: 403 | c = deepcopy(config_all) 404 | config_layout = validators._determine_config_layout(config_all) 405 | if config_layout.find("without") == -1: 406 | c["tabs"][0]["items"][0]["item_description"] = value 407 | else: 408 | c["items"][0]["item_description"] = value 409 | error_messages = [] 410 | error_messages = validators._validate_schema(error_messages, c) 411 | if error_messages: 412 | raise AssertionError 413 | 414 | 415 | @pytest.mark.breaking 416 | @pytest.mark.run(order=3) 417 | class TestBreakingSchemasItems: 418 | """Tests items in schemas to ensure they break appropriately""" 419 | 420 | def test_3_required_keys_absent(self, config_all): 421 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 422 | for case in ["item_choice_displayed", "item_inputs", "item_returns"]: 423 | c = deepcopy(config_all) 424 | config_layout = validators._determine_config_layout(config_all) 425 | if config_layout.find("without") == -1: 426 | c["tabs"][0]["items"][0] = del_key_if_present(c["tabs"][0]["items"][0], case) 427 | else: 428 | c["items"][0] = del_key_if_present(c["items"][0], case) 429 | validate_iteration_fail(c, "schema.SchemaMissingKeyError: Missing key: ", case) 430 | 431 | def test_2_keys_none_or_len_0(self, config_all): 432 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 433 | for key in ["item_choice_displayed", "item_returns"]: 434 | for value in [None, ""]: 435 | case = "{}_{}".format(key, value) 436 | c = deepcopy(config_all) 437 | config_layout = validators._determine_config_layout(config_all) 438 | if config_layout.find("without") == -1: 439 | c["tabs"][0]["items"][0][key] = value 440 | else: 441 | c["items"][0][key] = value 442 | validate_schema_fail(c, ["item#0: schema.SchemaError: Key ", ""], case) 443 | 444 | def test_item_description_len_0(self, config_all): 445 | _ = self.__class__ # just to get rid of codacy warning, I know, it's stupid 446 | c = deepcopy(config_all) 447 | config_layout = validators._determine_config_layout(config_all) 448 | if config_layout.find("without") == -1: 449 | c["tabs"][0]["items"][0]["item_description"] = "" 450 | else: 451 | c["items"][0]["item_description"] = "" 452 | validate_schema_fail(c, ["item#0: schema.SchemaError: Key ", "", value) 517 | 518 | 519 | @pytest.mark.function 520 | @pytest.mark.run(order=1) 521 | def test_fn__config_tabs(config_all): 522 | """Hard to make this non tautological""" 523 | tabs = validators._config_tabs(config_all) 524 | if not isinstance(tabs[0]["items"], list): 525 | raise AssertionError() 526 | 527 | 528 | # REPEAT VALUES TESTS # 529 | 530 | 531 | @pytest.mark.function 532 | @pytest.mark.run(order=2) 533 | def test_fn__validate_no_return_value_overlap(config_all): 534 | """Expect pass on valid input data""" 535 | error_messages = [] 536 | error_messages = validators._validate_no_return_value_overlap(error_messages, config_all) 537 | if error_messages: 538 | raise AssertionError 539 | 540 | 541 | @pytest.mark.function 542 | @pytest.mark.run(order=2) 543 | def test_fn__validate_no_input_value_overlap(config_all): 544 | """Expect pass on valid input data""" 545 | error_messages = [] 546 | error_messages = validators._validate_no_input_value_overlap(error_messages, config_all) 547 | if error_messages: 548 | raise AssertionError 549 | 550 | 551 | @pytest.mark.breaking 552 | @pytest.mark.run(order=3) 553 | def test_validate_no_return_value_overlap_fail(config_all_with_id): 554 | """Add a duplicate return value in a tab. Case sensitivity does not apply to return values""" 555 | conf, id_ = config_all_with_id 556 | c = deepcopy(conf) 557 | if id_.find("without") == -1: 558 | c["tabs"][0]["items"] += [c["tabs"][0]["items"][-1]] 559 | else: 560 | c["items"] += [c["items"][-1]] 561 | error_messages = [] 562 | error_messages = validators._validate_no_return_value_overlap(error_messages, c) 563 | if all([x.find("there are repeated return values") == -1 for x in error_messages]): 564 | raise AssertionError 565 | 566 | 567 | @pytest.mark.breaking 568 | @pytest.mark.run(order=3) 569 | def test_validate_no_input_value_overlap_fail_within_an_item(config_all_with_id, random_string): 570 | """Add a duplicate valid_input value within an item. Tests both case sensitive and insensitive""" 571 | conf, id_ = config_all_with_id 572 | for case_sens in [True, False]: 573 | c = deepcopy(conf) 574 | c["case_sensitive"] = case_sens 575 | if case_sens: 576 | new_returns = [random_string, random_string] 577 | else: 578 | new_returns = [random_string, random_string.lower()] 579 | if id_.find("without") == -1: 580 | c["tabs"][0]["items"][0]["item_inputs"] += new_returns 581 | else: 582 | c["items"][0]["item_inputs"] += new_returns 583 | error_messages = [] 584 | validators._validate_no_input_value_overlap(error_messages, c) 585 | if all([x.find("there are repeated input values") == -1 for x in error_messages]): 586 | raise AssertionError 587 | 588 | 589 | @pytest.mark.breaking 590 | @pytest.mark.run(order=3) 591 | def test__validate_no_input_value_overlap_fail_between_item_and_tab(config_multiple, random_string): 592 | """Makes one tab header value equal to another's return value, both case sensitive and insensitive""" 593 | for case_sens in [True, False]: 594 | c = deepcopy(config_multiple) 595 | c["case_sensitive"] = case_sens 596 | c["tabs"][0]["tab_header_input"] = random_string 597 | if case_sens: 598 | c["tabs"][1]["items"][0]["item_inputs"] += [random_string] 599 | else: 600 | c["tabs"][1]["items"][0]["item_inputs"] += [random_string.lower()] 601 | error_messages = [] 602 | validators._validate_no_input_value_overlap(error_messages, c) 603 | if all([x.find("there are repeated input values") == -1 for x in error_messages]): 604 | raise AssertionError 605 | 606 | 607 | @pytest.mark.integration 608 | @pytest.mark.run(order=4) 609 | def test_fn_validate_all(config_all): 610 | """Ensures each test case overall validation passes before making them fail for different reasons""" 611 | validators.validate_all(config_all) 612 | 613 | 614 | @pytest.mark.integration 615 | @pytest.mark.run(order=4) 616 | def test_fn_validate_all_fail(config_all): 617 | """Ensures each test case overall validation passes before making them fail for different reasons""" 618 | c = deepcopy(config_all) 619 | c[ 620 | "unrecognized_key" 621 | ] = "astring" # this particular error will cause the error message to be shortened, testing that function 622 | with pytest.raises(validators.InvalidInputError): 623 | validators.validate_all(c) 624 | 625 | 626 | # MISC TESTS 627 | 628 | 629 | @pytest.mark.function 630 | @pytest.mark.run(order=1) 631 | def test__shorten_long_schema_error_messages(): 632 | """Tests that function""" 633 | error_messages = ["stuff that comes at the beginning in {'key': 'value'}"] 634 | error_messages = validators._shorten_long_schema_error_messages(error_messages) 635 | if error_messages[0] != "stuff that comes at the beginning in config": 636 | raise AssertionError 637 | --------------------------------------------------------------------------------