├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── doc.md │ ├── feature_request.md │ └── task.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── docs ├── advanced │ ├── docstrings.md │ ├── how_dynacli_works.md │ ├── search-path.md │ ├── state-machine.md │ ├── types.md │ └── why.md ├── img │ ├── dynacli_search_paths_and_root_packages.png │ ├── dynacli_statemachine.png │ ├── dynacli_structure.png │ ├── manipulating_search_path.png │ ├── state_machine.png │ └── state_machine.puml ├── index.md ├── manual │ ├── cli-entrypoint.md │ ├── index.md │ ├── module-as-feature.md │ ├── package-as-feature.md │ └── top-level-command.md ├── todo-app │ ├── index.md │ ├── init.md │ ├── remove-project.md │ ├── rename-project.md │ └── task │ │ ├── task-add.md │ │ ├── task-clear.md │ │ ├── task-delete.md │ │ ├── task-done.md │ │ ├── task-list.md │ │ ├── todo-app-database-handler.md │ │ └── todo-controller-class.md └── types.md ├── mkdocs.yml ├── mypy.ini ├── pyproject.toml ├── requirements.txt ├── scripts ├── clean.sh ├── format-imports.sh ├── format.sh └── lint.sh ├── src └── dynacli │ ├── __init__.py │ ├── bootstrap │ ├── __init__.py │ ├── _cli.py │ ├── _sample_cli │ └── init.py │ └── dynacli.py ├── test ├── __init__.py └── integrated │ ├── __init__.py │ ├── storage_F │ └── cli │ │ └── dev │ │ └── feature_A │ │ ├── __init__.py │ │ ├── color.py │ │ ├── colors.py │ │ └── extra_colors.py │ ├── storage_X │ └── cli │ │ ├── admin │ │ └── feature_C │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ └── user.py │ │ └── dev │ │ ├── destroy.py │ │ ├── feature_A │ │ ├── __init__.py │ │ ├── create.py │ │ ├── destroy.py │ │ ├── init.py │ │ ├── new.py │ │ ├── package.py │ │ └── shutdown.py │ │ ├── service.py │ │ └── update.py │ ├── storage_Y │ └── cli │ │ ├── admin │ │ └── feature_D │ │ │ ├── __init__.py │ │ │ └── admin.py │ │ └── dev │ │ ├── fake.py │ │ ├── feature_B │ │ ├── __init__.py │ │ ├── color.py │ │ ├── create.py │ │ ├── destroy.py │ │ ├── distance.py │ │ ├── make.py │ │ ├── shape.py │ │ ├── terminate.py │ │ └── unknown_world.py │ │ ├── the_last.py │ │ └── upload.py │ ├── storage_Z │ └── cli │ │ ├── admin │ │ └── feature_Z │ │ │ ├── __init__.py │ │ │ ├── admin.py │ │ │ ├── start.py │ │ │ └── user.py │ │ └── dev │ │ ├── _common │ │ ├── __init__.py │ │ └── session.py │ │ ├── feature_A │ │ ├── __init__.py │ │ ├── feature_B │ │ │ ├── __init__.py │ │ │ ├── create.py │ │ │ ├── destroy.py │ │ │ ├── init.py │ │ │ ├── new.py │ │ │ ├── package.py │ │ │ └── shutdown.py │ │ ├── feature_C │ │ │ ├── __init__.py │ │ │ ├── create.py │ │ │ └── destroy.py │ │ └── feature_D │ │ │ └── __init__.py │ │ ├── feature_B │ │ ├── __init__.py │ │ ├── feature_F │ │ │ ├── __init__.py │ │ │ └── service.py │ │ └── new.py │ │ ├── feature_C │ │ ├── __init__.py │ │ ├── feature_F │ │ │ ├── __init__.py │ │ │ └── service.py │ │ ├── new.py │ │ └── service.py │ │ ├── feature_D │ │ ├── __init__.py │ │ └── new.py │ │ ├── feature_F │ │ └── __init__.py │ │ └── service.py │ ├── suite │ ├── testcli │ ├── testcli --version │ ├── testcli -h │ ├── testcli -v │ ├── testcli destroy awesome │ ├── testcli fake -h │ ├── testcli feature-A │ ├── testcli feature-A -h │ ├── testcli feature-A create cloudenv mypackage name1=lib1 name2=lib2 name3=lib3 name4=lib4 │ ├── testcli feature-A destroy cloudenv mypackage │ ├── testcli feature-A destroy cloudenv mypackage 1 2 3 4 │ ├── testcli feature-A destroy cloudenv mypackage 1 2 3 4 name1=1 name2=2 name3=3 name4=4 │ ├── testcli feature-A destroy cloudenv mypackage name1=1 name2=2 name3=3 name4=4 │ ├── testcli feature-A init myproject --path fff │ ├── testcli feature-A new │ ├── testcli feature-A new world │ ├── testcli feature-A package cloudenv mypackage lib1 lib2 lib3 lib4 │ ├── testcli feature-A shutdown cloudenv servicename │ ├── testcli feature-A shutdown cloudenv servicename xxxx │ ├── testcli feature-B │ ├── testcli feature-B --version │ ├── testcli feature-B -h │ ├── testcli feature-B -v │ ├── testcli feature-B color myenv RED │ ├── testcli feature-B destroy --version │ ├── testcli feature-B destroy -v │ ├── testcli feature-B distance -h │ ├── testcli feature-B make myenv True │ ├── testcli feature-B shape │ ├── testcli feature-B shape myenv CIRCLE │ ├── testcli feature-B shape myenv CIRCLE lib1 lib2 name1=lib1 name2=lib2 │ ├── testcli feature-B shape myenv XXXX lib1 name1=lib1 │ ├── testcli feature-B unknown-world na-me add-ress │ ├── testcli service │ ├── testcli service -h │ ├── testcli service new xxxx │ ├── testcli the-last -h │ ├── testcli update │ ├── testcli update --version │ ├── testcli update -v │ ├── testcli upload │ ├── testcli upload --version │ ├── testcli upload -h │ ├── testcli upload -v │ ├── testcliadmin -h │ ├── testcliadmin feature-C -h │ ├── testcliadmin feature-C user shako rzayev address=Baku age=32 │ ├── testclinested -h │ ├── testclinested feature-A -h │ ├── testclinested feature-A feature-B -h │ ├── testclinested feature-A feature-C -h │ ├── testclinested feature-A feature-C create asdas asdasd │ ├── testclinested feature-B feature-F service -h │ ├── testclinested feature-B feature-F service new myservice │ ├── testclinested feature-B new myservice │ ├── testclinested feature-C -h │ ├── testclinested feature-C service -h │ ├── testclinested feature-D -h │ ├── testclinested feature-F -h │ ├── testclinested feature-Z start name extra-arg=wohoo │ ├── testclisimple -h │ ├── testclisimple feature-A -h │ ├── testenum -h │ ├── testenum feature-A -h │ ├── testenum feature-A color myenv │ ├── testenum feature-A color myenv RED name=value │ ├── testenum feature-A colors myenv RED GREEN color=BLUE │ └── testenum feature-A colors myenv RED GREEN color=nope │ ├── test_dynacli.py │ ├── testcli │ ├── testcliadmin │ ├── testclinested │ ├── testclisimple │ ├── testenum │ └── tmp_ignored_tests │ ├── testcli fake change -h │ ├── testcli fake detect -h │ ├── testcli fake drop -h │ ├── testcli fake lonely -h │ ├── testcli fake love -h │ ├── testcli fake remove -h │ ├── testcli fake unsupported -h │ ├── testcli feature-A destroy -h │ ├── testcli feature-A new -h │ ├── testcli feature-A new world -h │ ├── testcli feature-A yyyy │ ├── testcli feature-B color -h │ ├── testcli feature-B create -h │ ├── testcli feature-B shape -h │ ├── testcli feature-C -h │ ├── testcli update -h │ ├── testcli upload new -h │ ├── testcli xxxx │ ├── testclinested feature-A feature-B create -h │ └── testclinested feature-B -h └── tutorials └── greetings ├── hello.py └── say /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B9 4 | ignore = E203, E501, W503, C812 5 | exclude = __init__.py -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | 14 | **To Reproduce** 15 | Steps to reproduce the behavior: 16 | 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | 26 | **Extra info:** 27 | - OS: [e.g. Amazon Linux 2] 28 | - Affected Version 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Create a documentation issue and assign 4 | title: "[DOC]" 5 | labels: 'documentation' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the documentation issue** 11 | A clear and concise description of what is wrong with our documentation. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Task 3 | about: Create a task and assign 4 | title: "[TASK]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the task** 11 | A clear and concise description of what the task is. 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Change Summary 2 | 3 | ## Description 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | jobs: 9 | deploy-documentation: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | with: 15 | python-version: 3.9.6 16 | - run: pip install mkdocs-material 17 | - run: mkdocs gh-deploy --force 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v3 15 | with: 16 | python-version: 3.9.6 17 | architecture: x64 18 | - run: pip install flake8==4.0.1 19 | - run: pip install black==22.3.0 20 | - run: pip install isort==5.9.3 21 | - run: scripts/lint.sh 22 | shell: bash 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy-package: 9 | runs-on: ubuntu-20.04 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | with: 15 | python-version: 3.9.6 16 | architecture: x64 17 | - run: python -m pip install --upgrade pip 18 | - run: pip install flit==3.6.0 19 | - name: Build and publish 20 | env: 21 | FLIT_USERNAME: __token__ 22 | FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 23 | run: flit publish -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - main 7 | - develop 8 | 9 | jobs: 10 | test: 11 | name: test py${{ matrix.python-version }} on linux 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ['3.8', '3.9.6'] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - run: pip install flit==3.6.0 23 | - run: flit install 24 | - run: cd test/integrated; python3 -m unittest test_dynacli.py 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | .venv38 4 | build 5 | dist 6 | opencli.egg-info 7 | dynacli.egg-info 8 | __pycache__/ 9 | *.py[cod] 10 | .dmypy.json -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.9 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: local 8 | hooks: 9 | - id: formatter 10 | name: formatter 11 | entry: scripts/format-imports.sh 12 | language: script 13 | types: [python] 14 | pass_filenames: false 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestArgs": [ 3 | "-v", 4 | "-s", 5 | "./test/integrated", 6 | "-p", 7 | "*test*.py" 8 | ], 9 | "python.testing.pytestEnabled": false, 10 | "python.testing.unittestEnabled": true, 11 | "editor.formatOnSaveMode": "file", 12 | "editor.formatOnSave": true, 13 | "editor.codeActionsOnSave": { 14 | "source.organizeImports": true, 15 | }, 16 | "python.linting.enabled": true, 17 | "python.linting.pylintEnabled": true, 18 | "python.formatting.provider": "black", 19 | "workbench.colorCustomizations": { 20 | "titleBar.activeBackground": "#4406d3" 21 | } 22 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2022 BST LABS 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DynaCLI 2 | [![Downloads](https://static.pepy.tech/personalized-badge/dynacli?period=total&units=none&left_color=blue&right_color=orange&left_text=Downloads)](https://pepy.tech/project/dynacli) 3 | [![](https://img.shields.io/pypi/v/dynacli?label=PyPi)](https://pypi.org/project/dynacli/) 4 | [![](https://img.shields.io/github/license/BstLabs/py-dynacli?color=blue&label=License)](https://github.com/BstLabs/py-dynacli/blob/main/LICENSE.md) 5 | 6 | DynaCLI (Dynamic CLI) is a cloud-friendly Python library for converting pure Python functions into Linux Shell commands on the fly. 7 | 8 | It's ideal for automating routine development and administrative tasks in a modern cloud software environment because it supports converting a virtually unlimited set of functions into Shell commands with minimal run-time and maintenance overhead. 9 | 10 | Unlike other existing solutions such as [Click](https://click.palletsprojects.com/en/8.0.x/) and [Typer](https://typer.tiangolo.com/), there is no need for any function decorators. Further, unlike with all existing solutions, including those built on top of standard [argparse](https://docs.python.org/3/library/argparse.html), DynaCLI does not build all command parsers upfront, but rather builds dynamically a single command parser based on the command line inputs. When combined with the [Python Cloud Importer](https://asher-sterkin.medium.com/serverless-cloud-import-system-760d3c4a60b9) solution, DynaCLI becomes truly _open_ with regard to a practically unlimited set of commands, all coming directly from cloud storage. This, in turn, eliminates any need for periodic updates on client workstations. 11 | 12 | At its core, DynaCLI is a Python package structure interpreter which makes any public function executable from the command line. 13 | 14 | DynaCLI was developed by BST LABS as an open source generic infrastructure foundation for the cloud version of Python run-time within the scope of the [Cloud AI Operating System (CAIOS)](http://caios.io) project. 15 | 16 | For details about the DynaCLI rationale and design considerations, refer to [DynaCLI Github Pages](https://bstlabs.github.io/py-dynacli/). 17 | 18 | ## Installation 19 | 20 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install DynaCLI from the PyPi site: 21 | 22 | ```bash 23 | pip3 install dynacli 24 | ``` 25 | 26 | ## Usage 27 | 28 | ## Define command line interpreter entry point 29 | 30 | You can use `dynacli init path=` command for bootstrapping the entry point file: 31 | 32 | ```bash 33 | $ cd tutorials/greetings 34 | 35 | $ dynacli init say path=. 36 | Successfully created CLI entrypoint say at /home/ssm-user/OSS/py-dynacli/tutorials/greetings 37 | ``` 38 | 39 | The created `say` file has some comments to change accordingly: 40 | 41 | ```python 42 | #!/usr/bin/env python3 43 | 44 | """ 45 | DynaCLI bootstrap script # Change me 46 | """ 47 | 48 | 49 | import os 50 | import sys 51 | from typing import Final 52 | 53 | from dynacli import main 54 | 55 | cwd = os.path.dirname(os.path.realpath(__file__)) 56 | 57 | __version__: Final[str] = "0.0.0" # Change me to define your own version 58 | 59 | 60 | search_path = [cwd] # Change me if you have different path; you can add multiple search pathes 61 | sys.path.extend(search_path) 62 | # root_packages = ['cli.dev', 'cli.admin'] # Change me if you have predefined root package name 63 | # main(search_path, root_packages) # Uncomment if you have root_packages defined 64 | 65 | main(search_path) 66 | 67 | ``` 68 | 69 | Let's change it: 70 | 71 | ```python 72 | #!/usr/bin/env python3 73 | """ 74 | Greetings CLI 75 | """ 76 | 77 | import sys 78 | import os 79 | from typing import Final 80 | 81 | from dynacli import main 82 | 83 | cwd = os.path.dirname(os.path.realpath(__file__)) 84 | 85 | __version__: Final[str] = "1.0" 86 | 87 | search_path = [cwd] 88 | sys.path.extend(search_path) 89 | 90 | main(search_path) 91 | ``` 92 | 93 | That is it, now we have ready to go CLI. 94 | 95 | ## Define commands 96 | 97 | Every public function in your search path will be treated as a command. For example, 98 | 99 | ```python 100 | def hello(*names: str) -> None: 101 | """ 102 | Print Hello message 103 | 104 | Args: 105 | names (str): variable list of names to be included in greeting 106 | 107 | Return: None 108 | """ 109 | print(f"Hello, {' '.join(names)}") 110 | ``` 111 | 112 | ## Start using CLI 113 | 114 | Let's get the help message: 115 | 116 | ```bash 117 | $ ./say -h 118 | usage: say [-h] [-v] {hello} ... 119 | 120 | Greetings CLI 121 | 122 | positional arguments: 123 | {hello} 124 | hello Print Hello message 125 | 126 | optional arguments: 127 | -h, --help show this help message and exit 128 | -v, --version show program's version number and exit 129 | ``` 130 | 131 | We can get the version as easy as: 132 | 133 | ```bash 134 | $ ./say --version 135 | say - v1.0 136 | ``` 137 | 138 | Now the help about actual command: 139 | 140 | ```bash 141 | $ ./say hello -h 142 | usage: say hello [-h] [names ...] 143 | 144 | positional arguments: 145 | names variable list of names to be included in greeting 146 | 147 | optional arguments: 148 | -h, --help show this help message and exit 149 | ``` 150 | 151 | Finally we can run the actual command(the hello function in fact) as: 152 | 153 | ```bash 154 | $ ./say hello Shako Rzayev Asher Sterkin 155 | Hello, Shako Rzayev Asher Sterkin 156 | ``` 157 | 158 | Go to [tutorials/greetings](tutorials/greetings) folder and try it yourself. 159 | 160 | ## Read the full documentation 161 | 162 | [DynaCLI Github Pages](https://bstlabs.github.io/py-dynacli/) 163 | 164 | 165 | ## License 166 | 167 | MIT License, Copyright (c) 2021-2022 BST LABS. See [LICENSE](LICENSE.md) file. 168 | -------------------------------------------------------------------------------- /docs/advanced/docstrings.md: -------------------------------------------------------------------------------- 1 | # Supported Docstring format 2 | 3 | You can read about available docstring formats in this article: [Docstring Formats](https://realpython.com/documenting-python-code/#docstring-formats) 4 | 5 | We opt for Google-style which is described here: [PyGuide Functions and Methods](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) 6 | 7 | You can use the following docstring as a reference: 8 | 9 | ```py title="test.py" 10 | def test(name: str, age: int, is_student: bool, *args: str, **kwargs: int) -> None: 11 | """ 12 | The test function... 13 | 14 | Args: 15 | name (str): name of the applicant 16 | age (int): age of the applicant 17 | is_student (bool): if the applicant is a student or not 18 | *args (str): some variable length arguments 19 | **kwargs (int): keyword arguments 20 | 21 | Return: None 22 | """ 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/advanced/how_dynacli_works.md: -------------------------------------------------------------------------------- 1 | # How DynaCLI Works 2 | 3 | DynaCLI library is a _simple_ preprocessor for the [argparse](https://docs.python.org/3/library/argparse.html) standard Python library. It scans the [sys.argv](https://docs.python.org/3/library/sys.html) array to process command line arguments one by one, uses the [importlib.import_module](https://docs.python.org/3/library/importlib.html#importlib.import_module) function to bring in feature packages/modules and command modules, uses the [inspect.signature](https://docs.python.org/3/library/inspect.html#inspect.signature) to understand command function arguments and the [re.match](https://docs.python.org/3/library/re.html#re.match) function to extract help string per argument. If it encounters a StopIteration or ModuleNotFound exception, it will use the [pkgutil.iter_modules](https://docs.python.org/3/library/pkgutil.html) function to build help for available features and commands. 4 | 5 | The overall structure of the DynaCLI library is illustrated below: 6 | 7 | [![DynaCLI Structure](../img/dynacli_structure.png)](../img/dynacli_structure.png) 8 | -------------------------------------------------------------------------------- /docs/advanced/search-path.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material/emoticon-happy 3 | --- 4 | 5 | # DynaCLI search path and root packages 6 | 7 | DynaCLI is supposed to be easy to use in easy cases and at the same time extremely flexible and cloud friendly. The last concept deserves some extra explanations. 8 | 9 | The DynaCLI was conceived to address the special needs of the [Cloud AI Operating System (CAIOS)](https://medium.com/@CAIOStech/improve-devsecops-10x-by-embracing-caios-c0ace31a3f33) project, which employs a special custom [Python Cloud Importer](https://asher-sterkin.medium.com/serverless-cloud-import-system-760d3c4a60b9) as the major underlying technology. Briefly, the main idea behind the [CAIOS Python Cloud Importer](https://asher-sterkin.medium.com/serverless-cloud-import-system-760d3c4a60b9) is that developers do not need to `pip install` anything, but just to `import what_your_need` from the cloud storage. 10 | 11 | Here, we could envision at least three types of cloud storage where importable artifacts could be located: 12 | 13 | * the main system cloud storage is shared by everybody (System Storage on the diagram below) 14 | * cloud storage of a particular system installation shared by a group of developers (Group Storage on the diagram below) 15 | * individual developer's storage (User Storage on the diagram below) 16 | 17 | Therefore, when a developer types `import something` this `something` could be imported from his/her User Storage, Group Storage shared with developers from her group, and System Storage shared with all developers. In fact, this is just the most typical system configuration. By itself, DynaCLI does not limit how many, or which types of storage should be in the system as long as they all are supported by the underlying Python Import System. Regardless of how many storages there are, each such storage should be reflected in the DynaCLI search_path list. 18 | 19 | On the other hand, in general cases, not all commands should be available to any user. Some commands are intended for developers but could be occasionally used by administrators. Some other commands should be available only for administrators of particular system installations (Group Administrator on the diagram below), while some other commands should be available for the system administrators. We, therefore need some form of command access control, be it [Role-Base Access Control (RBAC)](https://en.wikipedia.org/wiki/Role-based_access_control) or even [Attribute-Based Access Control (ABAC)](https://en.wikipedia.org/wiki/Attribute-based_access_control). 20 | 21 | DynaCLI addresses this need by assuming that each set of commands intended for a particular group of users (developers, administrators, etc.) is located under a particular Python Root Package and allows to configure them as a separate list in the DynaCLI entry point. 22 | 23 | Notice that the DynaCLI entry point does not have to be static. It could be dynamically generated at, say, system installation. 24 | 25 | Combining these two mechanisms, we come up with a many-to-many type of configuration, namely, that each type of storage might contain different root packages with commands intended for different types of users. This virtually unlimited flexibility in system configuration is what makes DynaCLI so different from any other Python CLI framework. 26 | 27 | Assuming AWS as a cloud platform (it will work equally well for any cloud, we just want to be specific), the diagram below illustrates a typical system configuration of 3 types of storage (System, Group, User) and two types of users (Developer, Administrator) accesses from two AWS cloud accounts: 28 | 29 | [![Search Paths and Root Packages](../img/dynacli_search_paths_and_root_packages.png)](../img/dynacli_search_paths_and_root_packages.png) 30 | 31 | Notice, that DynaCLI search path and root package configuration are not limited to cloud storage only (in this example AWS S3). Some artifacts could still be imported from a local disk for bootstrap or access time optimization purposes. There is virtually no limit to what could be achieved here. 32 | 33 | TODO: Shako to check if anything from below should be retained 34 | 35 | For DynaCLI search path has special meaning - it can use local packages or modules or those are stored in the cloud. 36 | There is no difference between whether it is getting imported from S3 or the local machine. 37 | 38 | Also, you can divide the functionality of your CLI - there can be situations where we need to provide some features for administrative users and some features to the developers. Basically, you can provide different search paths, and it will be reflected accordingly: 39 | 40 | 41 | Here we have 2 different CLIs `testcli` and `testcliadmin`. Accordingly, `testcliadmin` will see all features for the `dev` (`testcli`), because it is an admin: 42 | 43 | For Developers: 44 | 45 | ```console 46 | $ ./testcli -h 47 | usage: testcli [-h] [-v] {destroy,feature-A,service,update,fake,feature-B,the-last,upload} ... 48 | 49 | Sample DynaCLI Tool 50 | 51 | positional arguments: 52 | {destroy,feature-A,service,update,fake,feature-B,the-last,upload} 53 | destroy Destroy given name... 54 | feature-A Does something useful 55 | service This is an example of a module feature 56 | update Updates everything... 57 | fake [ERROR] Missing the module docstring 58 | feature-B Does something extremely useful 59 | the-last This is an example of a module feature 60 | upload This is an example of a module feature 61 | 62 | optional arguments: 63 | -h, --help show this help message and exit 64 | -v, --version show program's version number and exit 65 | ``` 66 | 67 | Administrators: 68 | 69 | ```console hl_lines="12 17" 70 | $ ./testcliadmin -h 71 | usage: testcliadmin [-h] [-v] {destroy,feature-A,service,update,feature-C,fake,feature-B,the-last,upload,feature-D} ... 72 | 73 | Sample DynaCLI Tool 74 | 75 | positional arguments: 76 | {destroy,feature-A,service,update,feature-C,fake,feature-B,the-last,upload,feature-D} 77 | destroy Destroy given name... 78 | feature-A Does something useful 79 | service This is an example of a module feature 80 | update Updates everything... 81 | feature-C For admin users 82 | fake [ERROR] Missing the module docstring 83 | feature-B Does something extremely useful 84 | the-last This is an example of a module feature 85 | upload This is an example of a module feature 86 | feature-D Do not forget about this feature for admins 87 | 88 | optional arguments: 89 | -h, --help show this help message and exit 90 | -v, --version show program's version number and exit 91 | ``` 92 | 93 | As you have noticed new 2 features were registered (`feature-C`, `feature-D`), and they can be either from the cloud or from the local. 94 | -------------------------------------------------------------------------------- /docs/advanced/state-machine.md: -------------------------------------------------------------------------------- 1 | # DynalCLI State Machine 2 | 3 | Internally, DynaCLI's main routine is implemented by applying the [State Design Pattern](https://en.wikipedia.org/wiki/State_pattern) (more specifically, _function_for_state_, but these are internal details) using a state machine for keeping track of the command line arguments processing progress. At a high level, this state machine, illustrated using the lightweight [UML Statecharts](https://en.wikipedia.org/wiki/UML_state_machine) notation, is presented below: 4 | 5 | [![State Machine](../img/dynacli_statemachine.png)](../img/dynacli_statemachine.png) 6 | 7 | We treat every command line argument as a trigger trying to figure out what to do about it: 8 | 9 | * the first entry of sys.argv is treated as a DynaCLI entry point, from which we extract high-level description and version info if any (_initial_state on the diagram) 10 | * every next argument is treated as a Python module name, imported using the [importlib.import_module](https://docs.python.org/3/library/importlib.html#importlib.import_module) function (_waiting_for_feature_or_command state on the diagram) 11 | * if the import fails, or there are more entries in sys.arg, help for all available features (at the current level) is generated using the [pkgutil.iter_modules](https://docs.python.org/3/library/pkgutil.html) function 12 | * if import succeeds, the imported module is analyzed whether it has an `__all__` specifier or not (we follow Python convention concerning `__all__` specification) 13 | * if it does have `__all__`, then based on whether it's a Python package or module, the processing transits to either `_waiting_for_package_feature_all` or `_waiting_for_module_feature_all` state (see below for more detailed description) 14 | * otherwise, if the imported module is a package, we extract the package help and version info, if any, using the `__init__.py` file docstring and `__version__` specification and push the [argparse](https://docs.python.org/3/library/argparse.html) parsers hierarchy one level down 15 | * if it's a regular module, we check if it has a function with the same name 16 | * if it does have a function with the same name, we treat it as a command module, build a complete command parser by analyzing the function signature and docstring, and transit to the final processing 17 | * if it does not have a function with the same module, we treat it as a feature module (each public function will be treated as a command) and transit to _waiting_for_feature_module_command_state 18 | * in the _waiting_for_package_feature_all state, we check whether the next sys.argv entry is in the `__all__` list; if it is we perform a normal state selection process outlined above, if it is not, we print a help message for this feature package 19 | * in the _waiting_for_module_feature_all state, we check whether the next sys.argv entry is in the `__all__` list; if it is we treat it as a command, if it is not, we print a help message for this feature module 20 | * in the _waiting_for_feature_module_command, we check whether the next sys.argv entry points to a public function within this module; if it does we treat it as a command, if it does not, we print a help message for this feature module 21 | * at the final stage, we invoke the [argparse](https://docs.python.org/3/library/argparse.html) standard parsing mechanism, check whether a pointer to the command function was obtained, and either execute this command if it was or print a usage message ([argparse](https://docs.python.org/3/library/argparse.html) will print an error message if something was wrong) 22 | 23 | ## Why [argparse](https://docs.python.org/3/library/argparse.html) at all? 24 | 25 | One could argue that DynaCLI uses the [argparse](https://docs.python.org/3/library/argparse.html) for printing help and usage messages while actual processing is done by the DynaCLI internal machinery. If so, the question would be "why use the [argparse](https://docs.python.org/3/library/argparse.html) at all". 26 | 27 | First, this observation is correct. Second, we wanted to retain the [argparse](https://docs.python.org/3/library/argparse.html) message formatting, which is considered a de-facto standard. Third, considering limited resources and specific needs of the main CAIOS project, we did not want to invest in excavating help and usage message formatting from the [argparse](https://docs.python.org/3/library/argparse.html) internals. While it might add some minor performance overhead we considered it negligible and worth our development effort savings. 28 | 29 | TODO: Shako to check whether anything from below still needs to be retained 30 | Each state corresponds to a different level. 31 | 32 | Effectively, we have 3 main states: feature as a package handler, feature as a module handler, and command handler. 33 | 34 | But there is a different state called `__all__` handler for going through a different path if there is a `__all__` 35 | indicated at feature as a package level and feature as a module level. 36 | 37 | There is no need to indicate `__all__` at the top-level command because it makes no sense. 38 | 39 | Basically, we treat each CLI sequence of the commands as different states: 40 | 41 | ```console 42 | $ ./testcli -h 43 | ``` 44 | 45 | States in this CLI run are described below: 46 | 47 | At each iteration we find ourselves in a specific state, yes we use State Design Pattern: 48 | 49 | First iteration -> `testcli` - the script itself is an initial state. 50 | 51 | Second iteration -> ``- feature as package or feature as module state. 52 | 53 | Third iteration -> `` - command state. 54 | 55 | Fourth iteration -> Iterator is exhausted and raised StopIteration which means we are going to build command help. 56 | 57 | Let's describe some more variations: 58 | 59 | Variations: 60 | 61 | `./testcli -h` : initial state - StopIteration - build all features help 62 | 63 | `./testcli -h` : initial state - add feature as parser - StopIteration - build feature help 64 | 65 | `./testcli -h` : initial state - add feature as parser - StopIteration - build feature as module help 66 | 67 | `./tescli -h` : initial state - add feature parser - add command parser - StopIteration - build command help 68 | 69 | `./tescli arg1 arg2 …` : initial state - add feature parser - add command parser - register arguments - execute the function 70 | 71 | And based on the fact that if `__all__` was found we got a different path to follow but the main idea is to have states for each path. 72 | 73 | [![State Machine](../img/state_machine.png)](../img/state_machine.png) 74 | 75 | *!!! NOTE* 76 | 77 | To explore UML please click and open the photo in large size -------------------------------------------------------------------------------- /docs/advanced/types.md: -------------------------------------------------------------------------------- 1 | # Supported Python Types 2 | 3 | Currently, we support the following Python types as argument types in functions: 4 | 5 | Supported: `int`, `float`, `str`, `bool`, `Enum` 6 | 7 | Unsupported: `Optional[]`, `Union[]`, `list`, `tuple`, `dict` etc. 8 | 9 | Even without unsupported type hints(and actual types) of arguments, you can easily replace them with `*args` and `**kwargs`, which are supported. 10 | -------------------------------------------------------------------------------- /docs/advanced/why.md: -------------------------------------------------------------------------------- 1 | # DynaCLI vs. Alternatives 2 | 3 | There are so many libraries out there for writing command-line utilities; why choose DynaCLI? 4 | 5 | Let's take a brief look at some common Python CLI libraries: 6 | 7 | - [Python argparse](https://docs.python.org/3/library/argparse.html) 8 | - [Google python-fire](https://google.github.io/python-fire/) 9 | - [Tiangolo Typer](https://typer.tiangolo.com/) 10 | - [Pallet's Click](https://click.palletsprojects.com/en/8.0.x/) 11 | 12 | We'll review them one by one trying to understand the benefits and limitations of each: 13 | 14 | ## [Python argparse](https://docs.python.org/3/library/argparse.html) 15 | 16 | DynaCLI is [built on top](./how_dynacli_works.md) of [argparse](https://docs.python.org/3/library/argparse.html) but the latter by itself is insufficient: it requires the manual construction of every parser. While this approach provides maximum flexibility, it's also tedious and error-prone. Also, typical usage of [argparse](https://docs.python.org/3/library/argparse.html) assumes building all parsers and sub-parsers upfront. The irony is that each CLI invocation will execute only one command, so all other CPU cycles are wasted. When the number of commands is large, it starts to be a serious problem exacerbated by the fact that, in the case of [Cloud AI Operating System (CAIOS)](http://caios.io), all command function modules come from cloud storage. Having said all this, [argparse](https://docs.python.org/3/library/argparse.html) establishes an industry-wide standard of how CLI help and usage messages should look, and DynaCLI uses it internally as explained in more details [here](./how_dynacli_works.md). 17 | 18 | ## [Google python-fire](https://google.github.io/python-fire/) 19 | 20 | This library shares with DynaCLI the main approach of converting ordinary Python functions into Bash commands. It even goes further, supporting class methods. DynaCLI does not support classes at the moment, but we may consider supporting them in the future (there is nothing spectacularly complex about classes). [Google python-fire](https://google.github.io/python-fire/) provides some additional attractive features such as function call chaining, interactivity, and shell completion. Like DynaCLI, [Google python-fire](https://google.github.io/python-fire/) is built on top of [Python argparse](https://docs.python.org/3/library/argparse.html) and uses its internal machinery for configuring parsers and help and usage messages. 21 | 22 | [Google python-fire](https://google.github.io/python-fire/) also supports custom serialization, keyword arguments (with -- prefix), and direct access to object properties and local variables. 23 | 24 | [Google python-fire](https://google.github.io/python-fire/) does not rely on type annotations but rather converts command-line arguments to the most suitable types automatically on the fly. 25 | 26 | However, unlike DynaCLI, [Google python-fire](https://google.github.io/python-fire/) is not _open_ concerning the potential number of commands and command groups (we call them features). Specifically, the main module should ```import fire``` (similar to ```from dynacli import main```), but it also assumes either defining in place or importing ALL functions and classes one wants to convert into Bash commands. DynaCLI does not do this; instead, it relies on the search path and root package configurations, based on which any number of Python functions will be converted into commands automatically. While DynaCLI does not support classes at the moment (we simply did not see enough need for them), it does support unlimited nesting of command groups (feature packages) as well as a correct interpretation of ```__all__``` specification and package ```__init__.py``` imports. 27 | 28 | As the result, the [Google python-fire](https://google.github.io/python-fire/) library is relatively large: i.e., 10s of Python modules. In comparison, DynaCLI comprises one Python module with less than 700 lines, including blanks and docstrings. Library size and several features mean complexity and stability, and we were looking for something as small as possible... we seldom, if at all, will need to update. 29 | 30 | The main difference between DynaCLI and [Google python-fire](https://google.github.io/python-fire/) is that it was built with a distinct strategic goal in mind: to provide a minimal footprint for a completely extensible set of administrative commands coming from vendors and customers alike. 31 | 32 | Many extra features of [Google python-fire](https://google.github.io/python-fire/) that are missing in DynaCLI could be added as dynamic plugins if we decide to support them. Custom serialization would be a good example. We deliberately decided not to support them at this time, arguing that it would increase complexity without too much benefit: custom conversions of string arguments could be easily implemented at the command function level without the introduction of a parallel plugin structure. Following similar logic, we decided not to support named and optional arguments. We preferred treating command functions as belonging to the [service layer](https://martinfowler.com/eaaCatalog/serviceLayer.html), restricted to built-in type arguments with basic support for variable-length parameters via ```*args``` and ```**kwargs```. Anything else could be implemented on top of that basic machinery without introducing added complexity and inflating the library's footprint. 33 | 34 | We will continue learning about [Google python-fire](https://google.github.io/python-fire/) and keeping track of its evolution. We will probably incorporate the most useful of its features into DynaCLI. 35 | 36 | ## [Tiangolo Typer](https://typer.tiangolo.com/) 37 | 38 | Conceptually, [Tiangolo Typer](https://typer.tiangolo.com/) usage is similar to that of [Google python-fire](https://google.github.io/python-fire/) - it converts plain Python functions into commands. Unlike [Google python-fire](https://google.github.io/python-fire/) and similar to DynaCLI, it does rely on argument types annotation. Unlike both of them, it is not implemented directly on top of [Python argparse](https://docs.python.org/3/library/argparse.html), but rather on top of [Pallet's Click](https://click.palletsprojects.com/en/8.0.x/), which of course, inflates the overall library footprint, which we were trying to avoid in DynaCLI. 39 | 40 | Like DynaCLI, it generates automatically commands' help from function docstrings and type annotations. 41 | 42 | Feature-wise, [Tiangolo Typer](https://typer.tiangolo.com/) is very close to [Google python-fire](https://google.github.io/python-fire/), but it leverages type annotations whenever possible. That in turn, allows effective integration with IDEs. 43 | 44 | It also uses [colorama](https://pypi.org/project/colorama/) for controlling output colors. For that purpose, [Tiangolo Typer](https://typer.tiangolo.com/) recommends using its special [```echo()```](https://typer.tiangolo.com/tutorial/printing/) function. In DynaCLI, we decided not to pursue this direction at the moment, permitting every command function to print or log whatever it needs. As with many other command-line tools, we want to be able to develop service functions equally utilizable via CLI and REST API interfaces. For that reason, using [Python Logging](https://docs.python.org/3/howto/logging.html) infrastructure is very often preferable. We also considered automatic printing (or logging) of function return values to be included in a future version. As with many other features, we want to avoid increasing the library footprint through features most daily operations could easily be performed without. 45 | 46 | Interestingly enough, [Tiangolo Typer](https://typer.tiangolo.com/) documentation [mentions](https://typer.tiangolo.com/alternatives/) two other CLI Python frameworks: [Hug](https://www.hug.rest/) and [Plac](https://plac.readthedocs.io/en/latest/). They both are based on Python function decorators and conceptually are similar to [Pallet's Click](https://click.palletsprojects.com/en/8.0.x/). 47 | 48 | The main limitation of [Tiangolo Typer](https://typer.tiangolo.com/) is the same as with [Google python-fire](https://google.github.io/python-fire/) - ALL command functions have to be brought in (aka imported) upfront, which violates the basic DynaCLI premise to be a completely _open_ and cloud-friendly library with a minimal installed footprint. By no means we were willing to trade these properties for more features and flexibility; more often than not these enhancements are not that critical or worth the extra complexity. 49 | 50 | ## [Pallet's Click](https://click.palletsprojects.com/en/8.0.x/) 51 | 52 | This is probably the most widely used and powerful Python CLI library. It does not seem to be implemented on top of [argparse](https://docs.python.org/3/library/argparse.html), but rather on top of [optparse](https://docs.python.org/3/library/optparse.html) - the [argparse](https://docs.python.org/3/library/argparse.html) predecessor, which was deprecated since Python version 3.2 and has not been further developed. 53 | 54 | It has a relatively large footprint by itself (this needs to be taken into consideration for [Tiangolo Typer](https://typer.tiangolo.com/)). 55 | 56 | The main feature of [Pallet's Click](https://click.palletsprojects.com/en/8.0.x/), which makes it so powerful and flexible, was an absolute no-go for us - it is based on [Python function decorators](https://www.python.org/dev/peps/pep-0318/). DynaCLI from the very outset was intended for converting into Bash commands regular Python functions that, at least in principle, could be reused in other contexts, such as REST API Services. 57 | 58 | ## Summary 59 | 60 | All the libraries mentioned above do not properly address the main DynaCLI requirements: 61 | 62 | - **complete openness** - all command functions are brought in via dynamic imports from, presumably, cloud storage. 63 | - **no function decorators** - command functions could be, at least in principle, reused in other contexts. 64 | - **minimal footprint** - the core library has to be as small and as stable as possible, built on top of the standard Python library. All extra features, if any, should be introduced via dynamic imports. 65 | 66 | At the moment, the DynaCLI library satisfies all requirements of the sponsoring [Cloud AI Operating System (CAIOS)](http://caios.io) project. Should additional needs or high-demand enhancements arise, such as command chaining or autocompletion, and these could be added without violating the main requirements outlined above, we will consider doing so in or accepting contributions to future versions of DynaCLI. 67 | -------------------------------------------------------------------------------- /docs/img/dynacli_search_paths_and_root_packages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/docs/img/dynacli_search_paths_and_root_packages.png -------------------------------------------------------------------------------- /docs/img/dynacli_statemachine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/docs/img/dynacli_statemachine.png -------------------------------------------------------------------------------- /docs/img/dynacli_structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/docs/img/dynacli_structure.png -------------------------------------------------------------------------------- /docs/img/manipulating_search_path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/docs/img/manipulating_search_path.png -------------------------------------------------------------------------------- /docs/img/state_machine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/docs/img/state_machine.png -------------------------------------------------------------------------------- /docs/img/state_machine.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | state _waiting_for_all <> 3 | 4 | [*] --> _initial_state 5 | _initial_state --> _waiting_for_feature_or_command 6 | _waiting_for_feature_or_command --> _waiting_for_all : [~__all~__ in module.~__dict~__] 7 | _waiting_for_feature_or_command --> _waiting_for_feature_or_command : [_is_package]/add_feature() 8 | _waiting_for_feature_or_command --> _waiting_for_feature_module_command : [_is_feature_module]/add_feature_parser() 9 | _waiting_for_feature_or_command --> [*] : /add_command_parser() 10 | _waiting_for_feature_or_command --> [*] : [StopIteration]/build_all_features_help() 11 | _waiting_for_all --> _waiting_for_package_feature_all : [_is_package]/add_feature() 12 | _waiting_for_all --> _waiting_for_module_feature_all : [else]/add_feature_parser() 13 | _waiting_for_package_feature_all --> [*] : [name in module.~__all~__]/add_command_parser() 14 | _waiting_for_package_feature_all --> [*] : [StopIteration]/build_features_help_with_all() 15 | _waiting_for_module_feature_all --> [*] : [name in module.~__all~__]/add_command_parser() 16 | _waiting_for_module_feature_all --> [*] : [StopIteration]/add_command_parser() 17 | _waiting_for_feature_module_command --> [*] : /add_command_parser() 18 | _waiting_for_feature_module_command --> [*] : [StopIteration]/build_feature_module_help() 19 | @enduml -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to DynaCLI 2 | 3 | --- 4 | 5 | **Documentation**: [DynaCLI Github Pages](https://bstlabs.github.io/py-dynacli/) 6 | 7 | **Source Code**: [py-dynacli](https://github.com/BstLabs/py-dynacli) 8 | 9 | --- 10 | 11 | DynaCLI (Dynamic CLI) is a cloud-friendly Python library for converting pure Python functions into Linux Shell commands on the fly. 12 | 13 | Unlike other existing solutions such as [Click](https://click.palletsprojects.com/en/8.0.x/) and [Typer](https://typer.tiangolo.com/), there is no need for any function decorators. Further, unlike all existing solutions, including those built on top of standard [argparse](https://docs.python.org/3/library/argparse.html), DynaCLI does not build all command parsers upfront, but rather builds dynamically a single command parser based on the command line inputs. When combined with the [Python Cloud Importer](https://asher-sterkin.medium.com/serverless-cloud-import-system-760d3c4a60b9) solution DynaCLI becomes truly _open_ concerning a practically unlimited set of commands, all coming directly from cloud storage. This, in turn, eliminates any need for periodic updates on client workstations. 14 | 15 | At its score, DynaCLI is a Python package structure interpreter which makes any public function executable from the command line. 16 | 17 | DynaCLI was developed by BST LABS as an open-source generic infrastructure foundation for the cloud version of Python run-time within the scope of the [Cloud AI Operating System (CAIOS)](http://caios.io) project. 18 | 19 | DynaCLI is based on Python 3.9+, standard Python docstrings, and Python type hints. 20 | 21 | DynaCLI vital differentiators are: 22 | 23 | * **Fast**: DynaCLI builds [argparse](https://docs.python.org/3/library/argparse.html) parser hierarchy only for one command thus eliminating the need for preparing parsers for all available commands. 24 | * **Open**: adding new command or group of commands (called feature) is as easy as dropping implementation module(s) in the right place of the import tree. 25 | * **Frameworkless**: no need to import anything in command modules. Just write plain Python functions with built-in type arguments (\*args and \*\*kwargs are supported as well). 26 | * **Zero dependencies**: only one module built on top of the standard Python library to install. No heavy dependencies dragged in. 27 | * **Robust**: potential defect in any command will not take down the whole system. 28 | -------------------------------------------------------------------------------- /docs/manual/cli-entrypoint.md: -------------------------------------------------------------------------------- 1 | # Building CLI 2 | 3 | We are going to build a simple CLI app in this tutorial. We called it `awesome`. 4 | 5 | First, let's define our project structure: 6 | 7 | ```console 8 | $ mkdir awesome 9 | $ touch awesome/awesome 10 | ``` 11 | 12 | Create the storages: 13 | 14 | ```console 15 | $ mkdir -p storage_X/cli/dev 16 | $ mkdir -p storage_Y/cli/dev 17 | ``` 18 | 19 | Now we will define our CLI entrypoint as: 20 | 21 | ```py 22 | #!/usr/bin/env python3 23 | 24 | """ 25 | DynaCLI bootstrap script # Change me 26 | """ 27 | 28 | 29 | import os 30 | import sys 31 | from typing import Final 32 | 33 | from dynacli import main 34 | 35 | cwd = os.path.dirname(os.path.realpath(__file__)) 36 | 37 | __version__: Final[str] = "1.0.0" 38 | 39 | search_path = [f'{cwd}/storage_X/cli/dev', f'{cwd}/storage_Y/cli/dev'] 40 | sys.path.extend(search_path) 41 | 42 | # root_packages = ['cli.dev', 'cli.admin'] # Change me if you have predefined root package name 43 | # main(search_path, root_packages) # Uncomment if you have root_packages defined 44 | 45 | main(search_path) 46 | ``` 47 | 48 | If you wonder what is this `search_path`, please refer to the [Search Path manipulation](../advanced/search-path.md) section of the Advanced Reference Manual. 49 | 50 | The next is to start adding packages as features. 51 | -------------------------------------------------------------------------------- /docs/manual/index.md: -------------------------------------------------------------------------------- 1 | # Reference Manual 2 | 3 | This reference shows you how to use DynaCLI with most of its features, step by step. 4 | 5 | Each section gradually builds on the previous ones, but it's structured to separate topics, 6 | so that you can go directly to any specific one to solve your specific needs. 7 | 8 | It is also built to work as a future reference. 9 | 10 | So you can come back and see exactly what you need. 11 | 12 | --- 13 | 14 | ## Install DynaCLI 15 | 16 | ```console 17 | $ pip3 install dynacli 18 | ``` 19 | 20 | As we have zero 3rd party dependencies, DynaCLI will just install a single module and that's it. 21 | -------------------------------------------------------------------------------- /docs/manual/module-as-feature.md: -------------------------------------------------------------------------------- 1 | # Module as feature 2 | 3 | The module as a feature is a standalone module that is not located in the package(I.E it is not a package as a feature). 4 | It is a regular `.py` module file with functions in it - but it has no identical named function in it. 5 | 6 | Let's add module as a feature called `upload.py`: 7 | 8 | ```console 9 | $ touch storage_X/cli/dev/upload.py 10 | ``` 11 | 12 | And add the docstring in the `upload.py` file: 13 | 14 | ``` py title="upload.py" 15 | """ 16 | This is an example of the module feature 17 | """ 18 | ``` 19 | 20 | If you run the CLI: 21 | 22 | ```console 23 | $ ./awesome -h 24 | usage: awesome [-h] {service,upload,environment} ... 25 | 26 | positional arguments: 27 | {service,upload,environment} 28 | service The service feature to handle our services # (1) 29 | upload This is an example of module feature # (2) 30 | environment The environment feature to handle our environments # (3) 31 | 32 | optional arguments: 33 | -h, --help show this help message and exit 34 | ``` 35 | 36 | 1. Package as a feature from storage_X 37 | 2. Module as a feature from storage_X 38 | 3. Package as a feature from storage_Y 39 | 40 | ## Feature Commands 41 | 42 | With package as a feature, the commands are modules with identically named functions in them. 43 | In contrast, here we are going to add multiple functions in the `upload.py` - effectively multiple commands. 44 | 45 | ```py title="upload.py" hl_lines="32 41" 46 | """ 47 | This is an example of the module feature 48 | """ 49 | 50 | def new(name: str) -> None: 51 | """ 52 | uploads a new file 53 | 54 | Args: 55 | 56 | name (str): Name of file 57 | 58 | Return: None 59 | """ 60 | print(f"This is a module as feature {name}") 61 | 62 | 63 | def delete(name: str, environment: str) -> None: 64 | """ 65 | Deletes a file from the given environment 66 | 67 | Args: 68 | 69 | name (str): Name of project 70 | environment (str): Name of the env 71 | 72 | Return: None 73 | """ 74 | print(f"Delete a module as feature {name} {environment}") 75 | 76 | 77 | def _init(): 78 | """ 79 | This should not be shown 80 | 81 | Return: None 82 | """ 83 | ... 84 | 85 | 86 | def __revert(): 87 | """ 88 | This should not be shown 89 | 90 | Return: None 91 | """ 92 | ... 93 | ``` 94 | 95 | **In Python convention something starting with a single and double underscore is considered "protected" and "private" respectively.** 96 | 97 | **We like this idea and those commands(functions) are silently ignored and are not considered as commands:** 98 | 99 | ```console hl_lines="5" 100 | $ ./awesome upload -h 101 | usage: awesome upload [-h] {new,delete} ... 102 | 103 | positional arguments: 104 | {new,delete} 105 | new uploads a new file 106 | delete Deletes a file from given environment 107 | 108 | optional arguments: 109 | -h, --help show this help message and exit 110 | ``` 111 | 112 | Finally, let's run this new command: 113 | 114 | ```console 115 | $ ./awesome upload new -h 116 | usage: awesome upload new [-h] name 117 | 118 | positional arguments: 119 | name Name of file 120 | 121 | optional arguments: 122 | -h, --help show this help message and exit 123 | ``` 124 | 125 | ```console 126 | $ ./awesome upload new file 127 | This is a module as a feature file 128 | ``` 129 | 130 | ## Versioning module as a feature 131 | 132 | As with packages as features, you can add `__version__` in the module as a feature to indicate your unique version: 133 | 134 | ```py title="upload.py" hl_lines="5" 135 | """ 136 | This is an example of the module feature 137 | """ 138 | 139 | __version__ = "5.0" 140 | 141 | def new(name: str) -> None: 142 | ``` 143 | 144 | Now you can get the version as well: 145 | 146 | ```console 147 | $ ./awesome upload new --version 148 | awesome upload new - v5.0 149 | ``` 150 | 151 | ## Limiting the feature commands 152 | 153 | If for some reason you have a "public" function in the module, and you do not want to expose it as a command you can limit it by using `__all__`. 154 | 155 | Originally in Python `__all__` only limits the imports such as: from something import *. 156 | 157 | But here we use it just for eliminating the redundant operations when we register the feature commands: 158 | 159 | ```py title="upload.py" hl_lines="7" 160 | """ 161 | This is an example of the module feature 162 | """ 163 | 164 | __version__ = "5.0" 165 | 166 | __all__ = ["new"] 167 | 168 | def new(name: str) -> None: 169 | ``` 170 | 171 | Now if you look at the help of the feature: 172 | 173 | ```console hl_lines="5" 174 | $ ./awesome upload -h 175 | usage: awesome upload [-h] [-v] {new} ... 176 | 177 | positional arguments: 178 | {new} 179 | new uploads a new file 180 | 181 | optional arguments: 182 | -h, --help show this help message and exit 183 | -v, --version show program's version number and exit 184 | ``` 185 | 186 | And if you try to bypass(because you are sure there is a delete function): 187 | 188 | ```console hl_lines="3" 189 | $ ./awesome upload delete -h 190 | usage: awesome upload [-h] [-v] {new} ... 191 | awesome upload: error: invalid choice: 'delete' (choose from 'new') 192 | ``` 193 | 194 | The next is to learn about top-level commands. 195 | -------------------------------------------------------------------------------- /docs/manual/package-as-feature.md: -------------------------------------------------------------------------------- 1 | # Package as a feature 2 | 3 | Now it is time to add our package as features: 4 | 5 | ```console 6 | $ mkdir storage_X/cli/dev/service 7 | $ touch storage_X/cli/dev/service/__init__.py 8 | 9 | $ mkdir storage_Y/cli/dev/environment 10 | $ touch storage_Y/cli/dev/environment/__init__.py 11 | ``` 12 | 13 | That's it you can now run your CLI: 14 | 15 | ```console hl_lines="7 8" 16 | $ ./awesome -h 17 | 18 | usage: awesome [-h] {service,environment} ... 19 | 20 | positional arguments: 21 | {service, environment} 22 | service [ERROR] Missing the module docstring 23 | environment [ERROR] Missing the module docstring 24 | 25 | optional arguments: 26 | -h, --help show this help message and exit 27 | ``` 28 | 29 | Our packages have no docstrings in them, due to this fact we got an `ERROR` indicating that we are missing the docstrings. 30 | 31 | Let's quickly fix this. We are going to add docstrings to the `__init__.py` files. 32 | 33 | Open the `storage_X/cli/dev/service/__init__.py` and add the following: 34 | 35 | ```py 36 | """The service feature to handle our services""" 37 | ``` 38 | 39 | Open the `storage_Y/cli/dev/environment/__init__.py` and add the following: 40 | 41 | ```py 42 | """The environment feature to handle our environments""" 43 | ``` 44 | 45 | Now if you rerun the CLI you can see that there are no `ERROR`s: 46 | 47 | ```console hl_lines="7 8" 48 | $ ./awesome -h 49 | 50 | usage: awesome [-h] {service,environment} ... 51 | 52 | positional arguments: 53 | {service, environment} 54 | service The service feature to handle our services 55 | environment The environment feature to handle our environments 56 | 57 | optional arguments: 58 | -h, --help show this help message and exit 59 | ``` 60 | 61 | ## Feature commands 62 | 63 | What kind of operations do we want for our service feature? 64 | Let's imagine that we can create, update, and shut down the services. 65 | That means we need `new.py`, `update.py`, and `shutdown.py` files in the service package: 66 | 67 | ```console 68 | $ touch storage_X/cli/dev/service/new.py 69 | $ touch storage_X/cli/dev/service/update.py 70 | $ touch storage_X/cli/dev/service/shutdown.py 71 | ``` 72 | 73 | We consider commands in the package as features if they have an identically named function in them. 74 | In other words, there should be `#!py new()` function in `new.py`, `#!py update()` in `update.py` etc. 75 | 76 | So, let's define our functions(feature commands): 77 | 78 | ```py title="new.py" 79 | def new(name: str, path: str): 80 | """ 81 | init the new project in the given path 82 | 83 | Args: 84 | name (str): name of the project 85 | path (str): path where to create service 86 | 87 | Return: None 88 | """ 89 | print(f"Initializing the {name} in {path}") 90 | ``` 91 | 92 | ```py title="update.py" 93 | def update(name: str, version: float, upgrade: bool, *args: str, **kwargs: int) -> None: 94 | """ 95 | updates the service... 96 | 97 | Args: 98 | name (str): name of the service 99 | version (float): new version 100 | upgrade (bool): if to upgrade everything 101 | *args (str): variable length arguments 102 | **kwargs (int): keyword arguments 103 | 104 | Return: None 105 | """ 106 | print(f"Updating...{name} to {version} with {upgrade=} using {args} and {kwargs}") 107 | ``` 108 | 109 | ```py title="shutdown.py" 110 | def shutdown(environment: str, service: str) -> None: 111 | """ 112 | shutdown the service 113 | 114 | Args: 115 | environment (str): environment name (e.g. Cloud9 IDE stack) 116 | service (str): name of the service 117 | 118 | Return: None 119 | """ 120 | print(f"This is a shutdown of {service} from {environment}!") 121 | ``` 122 | 123 | Now let's get information about service feature: 124 | 125 | ```console 126 | $ ./awesome service -h 127 | usage: awesome service [-h] {new,shutdown,update} ... 128 | 129 | positional arguments: 130 | {new,shutdown,update} 131 | new init the new project in the given path 132 | shutdown shutdown the service 133 | update updates the service... 134 | 135 | optional arguments: 136 | -h, --help show this help message and exit 137 | ``` 138 | 139 | How about each command? 140 | 141 | ```console 142 | $ ./awesome service update -h 143 | usage: awesome service update [-h] name version upgrade [args ...] [kwargs = ...] 144 | 145 | positional arguments: 146 | name name of the service 147 | version new version 148 | upgrade if to upgrade everything 149 | args variable length arguments 150 | kwargs = 151 | keyword arguments 152 | 153 | optional arguments: 154 | -h, --help show this help message and exit 155 | ``` 156 | 157 | Now let's call the update command: 158 | 159 | ```console 160 | $ ./awesome service update myservice 2.0 True lib1 lib2 version1=1.2 version2=1.3 161 | 162 | Updating... myservice to 2.0 with upgrade=True using ('lib1', 'lib2') and {'version1': 1.2, 'version2': 1.3} 163 | ``` 164 | 165 | As you have already noticed we have converted the CLI commands to the function arguments with proper type conversion. 166 | 167 | ## Versioning your features and commands 168 | 169 | Now imagine the case, when for some reason you have a bunch of features with different versions, and also your commands have different versioning. 170 | You can easily handle it, by adding `__version__` in the feature and commands. 171 | 172 | Open the `storage_X/cli/dev/service/__init__.py` and add: 173 | 174 | ```py hl_lines="3" 175 | """The service feature to handle our services""" 176 | 177 | __version__ = "1.0" 178 | ``` 179 | 180 | Now you can get the version of the feature: 181 | 182 | ```console 183 | $ ./awesome service --version 184 | 185 | awesome service - v1.0 186 | ``` 187 | 188 | Same for `update` command: 189 | 190 | ```py title="update.py" hl_lines="1" 191 | __version__ = "2.0" 192 | 193 | def update(name: str, version: float, upgrade: bool, *args: str, **kwargs: float) -> None: 194 | ... 195 | ``` 196 | 197 | ```console 198 | $ ./awesome service update --version 199 | 200 | awesome service update - v2.0 201 | ``` 202 | 203 | ## Limiting the feature commands 204 | 205 | You may have a situation when you have other helper modules inside the feature package, and you do not want to expose them as a feature command. 206 | In that case, you can leverage the `__all__` mechanism. Originally in Python `__all__` only limits the imports such as: `from something import *`. 207 | But here we use it just for eliminating the redundant operations when we register the feature commands. 208 | 209 | So let's eliminate the `shutdown` command from our `service` feature without removing it. 210 | 211 | Update the `__init__.py` file of the service feature: 212 | 213 | ```py hl_lines="7" 214 | """The service feature to handle our services""" 215 | 216 | from . import * 217 | 218 | __version__ = "1.0" 219 | 220 | __all__ = ["new", "update"] 221 | ``` 222 | 223 | And now try to get the help, as you have already noticed `shutdown` command is not available: 224 | 225 | ```console hl_lines="6" 226 | $ ./awesome service -h 227 | 228 | usage: awesome service [-h] [-v] {new,update} ... 229 | 230 | positional arguments: 231 | {new,update} 232 | new init the new project in given path 233 | update Updates the service... 234 | 235 | optional arguments: 236 | -h, --help show this help message and exit 237 | -v, --version show program's version number and exit 238 | ``` 239 | 240 | If you try to bypass this guard(because you know that there is a shutdown.py file indeed): 241 | 242 | ```console 243 | $ ./awesome service shutdown -h 244 | 245 | usage: awesome service [-h] [-v] {new,update} ... 246 | awesome service: error: invalid choice: 'shutdown' (choose from 'new', 'update') 247 | ``` 248 | 249 | The next is to explore modules as features. 250 | -------------------------------------------------------------------------------- /docs/manual/top-level-command.md: -------------------------------------------------------------------------------- 1 | # Top Level Command 2 | 3 | The top-level command is a module with an identically named function in it. It is similar to a package as a feature, except it is a module, not a package. 4 | I.E it is a module as a feature with an identically named function in it. 5 | 6 | Let's create sample one: 7 | 8 | ```console 9 | $ touch storage_X/cli/dev/destroy.py 10 | ``` 11 | 12 | Define the command as: 13 | 14 | ```py title="destroy.py" 15 | def destroy(name: str) -> None: 16 | """ 17 | Destroy given name...(top-level command) 18 | 19 | Args: 20 | 21 | name (str): Name of project 22 | 23 | Return: None 24 | """ 25 | print(f"This is a top level destroyer - {name}") 26 | ``` 27 | 28 | Get the help message: 29 | 30 | ```console 31 | $ ./awesome -h 32 | usage: awesome [-h] {destroy,service,upload,environment} ... 33 | 34 | positional arguments: 35 | {destroy,service,upload,environment} 36 | destroy Destroy given name...(top-level command) # (1) 37 | service The service feature to handle our services # (2) 38 | upload This is an example of module feature # (3) 39 | environment The environment feature to handle our environments # (4) 40 | 41 | optional arguments: 42 | -h, --help show this help message and exit 43 | ``` 44 | 45 | 1. Top-level command from storage_X 46 | 2. Package as a feature from storage_X 47 | 3. Module as a feature from storage_X 48 | 4. Package as a feature from storage_Y 49 | 50 | Get the top-level command help: 51 | 52 | ```console 53 | $ ./awesome destroy -h 54 | usage: awesome destroy [-h] name 55 | 56 | positional arguments: 57 | name Name of project 58 | 59 | optional arguments: 60 | -h, --help show this help message and exit 61 | ``` 62 | 63 | Run the top-level command: 64 | 65 | ```console 66 | $ ./awesome destroy please 67 | This is a top-level destroyer - please 68 | ``` 69 | 70 | ## Versioning 71 | 72 | You can add a unique version to your top level command by adding `__version__`: 73 | 74 | ```py title="destroy.py" hl_lines="1" 75 | __version__ = "1.1a1" 76 | 77 | def destroy(name: str) -> None: 78 | ``` 79 | 80 | Get the version information: 81 | 82 | ```console hl_lines="9" 83 | ./awesome destroy -h 84 | usage: awesome destroy [-h] [-v] name 85 | 86 | positional arguments: 87 | name Name of project 88 | 89 | optional arguments: 90 | -h, --help show this help message and exit 91 | -v, --version show program's version number and exit 92 | ``` 93 | 94 | ```console 95 | $ ./awesome destroy --version 96 | awesome destroy - v1.1a1 97 | ``` 98 | -------------------------------------------------------------------------------- /docs/todo-app/index.md: -------------------------------------------------------------------------------- 1 | # TODO app 2 | 3 | We use a To-Do app idea from [Build a Command-Line To-Do App With Python and Typer](https://realpython.com/python-typer-cli/). 4 | Of course, we are going to change and omit unneeded sections. The goal is to show how DynaCLI can ease the process of building CLI apps. 5 | 6 | Create the TODO directory and create the todo file: 7 | ```console 8 | $ mkdir TODO 9 | $ dynacli init todo path=TODO/ 10 | ``` 11 | 12 | ```bash 13 | $ tree 14 | . 15 | └── TODO 16 | └── todo 17 | 18 | 1 directory, 1 file 19 | ``` 20 | 21 | CLI entrypoint is quite simple without any pre-configuration: 22 | 23 | ```py title="todo" 24 | #!/usr/bin/env python3 25 | 26 | """ 27 | TODO CLI APP 28 | """ 29 | 30 | import os 31 | import sys 32 | from typing import Final 33 | 34 | from dynacli import main 35 | 36 | cwd = os.path.dirname(os.path.realpath(__file__)) 37 | 38 | __version__: Final[str] = "1.0.0" 39 | 40 | search_path = [cwd] 41 | sys.path.extend(search_path) 42 | 43 | main(search_path) 44 | ``` 45 | 46 | And now we have nice help with the description and the version. 47 | Essentially, we get the CLI help from the docstring, version from `__version__` and there is no need for any callback. 48 | 49 | ```console 50 | $ ./todo -h 51 | usage: todo [-h] [-v] {} ... 52 | 53 | TODO CLI APP 54 | 55 | positional arguments: 56 | {} 57 | 58 | optional arguments: 59 | -h, --help show this help message and exit 60 | -v, --version show program's version number and exit 61 | ``` 62 | 63 | ```console 64 | $ ./todo --version 65 | todo - v1.0 66 | ``` 67 | 68 | The next step is to initiate the TODO project. 69 | -------------------------------------------------------------------------------- /docs/todo-app/init.md: -------------------------------------------------------------------------------- 1 | # todo init command 2 | 3 | The simplest way of storing our todos is constructing a .json file with given name. 4 | At this point, it is different from [original post](https://realpython.com/python-typer-cli/), 5 | and we consider it is simpler to store tasks as: `(Status, Task name)` style in the `.json` file. 6 | We consider the database as a project name where the tasks should reside. 7 | If you want to create task management for your daily routine - that means, we need to init the daily database(or daily project). 8 | 9 | This is called initialization, so we have created the `init.py` file: 10 | 11 | ```console 12 | $ cd TODO 13 | $ touch init.py 14 | $ tree 15 | . 16 | └── TODO 17 | ├── init.py 18 | └── todo 19 | 20 | 1 directory, 2 files 21 | ``` 22 | 23 | ```py title="init.py" 24 | import json 25 | 26 | def init(project_name: str) -> None: 27 | """ 28 | Initialize the .json file with a given name 29 | 30 | Args: 31 | project_name (str): the name of the todo project 32 | 33 | Return: None 34 | """ 35 | data = {project_name: []} 36 | with open(f"{project_name}.json", "w") as f: 37 | json.dump(data, f) 38 | print("Created: ", project_name+".json") 39 | ``` 40 | 41 | That is it now we have a nice help message, and we can initialize our "database" - JSON file: 42 | 43 | ```console 44 | $ ./todo init -h 45 | usage: todo init [-h] project_name 46 | 47 | positional arguments: 48 | project_name the name of the todo project 49 | 50 | optional arguments: 51 | -h, --help show this help message and exit 52 | ``` 53 | 54 | Run the init command: 55 | 56 | ```console 57 | $ ./todo init daily 58 | Created: daily.json 59 | ``` 60 | 61 | The final tree: 62 | 63 | ```console 64 | $ tree -I __pycache__ 65 | . 66 | ├── init.py 67 | ├── daily.json 68 | └── todo 69 | 70 | 0 directories, 3 files 71 | ``` 72 | 73 | The next command is to implement the `todo remove` command - I.E deleting the ".json" file. 74 | -------------------------------------------------------------------------------- /docs/todo-app/remove-project.md: -------------------------------------------------------------------------------- 1 | # todo remove command 2 | 3 | Again, removing project(database) means removing our .json file. 4 | The most naive way is to create the `remove.py` file and pass the project name as an argument: 5 | 6 | ```console 7 | $ touch remove.py 8 | $ tree 9 | . 10 | └── TODO 11 | ├── init.py 12 | ├── remove.py 13 | └── todo 14 | 15 | 1 directory, 3 files 16 | ``` 17 | 18 | ```py title="remove.py" 19 | import os 20 | 21 | def remove(project_name: str) -> None: 22 | """ 23 | Remove the .json file with a given project name 24 | 25 | Args: 26 | project_name (str): The name of the project 27 | 28 | Return: None 29 | """ 30 | os.remove(f"{project_name}.json") 31 | print("Removed: ", project_name) 32 | ``` 33 | 34 | Let's get help and remove our `daily` project: 35 | 36 | ```console 37 | $ ./todo -h 38 | usage: todo [-h] [-v] {init,remove} ... 39 | 40 | TODO CLI APP 41 | 42 | positional arguments: 43 | {init,remove} 44 | init Initialize the .json file with given name 45 | remove Remove the .json file with given project name 46 | 47 | optional arguments: 48 | -h, --help show this help message and exit 49 | -v, --version show program's version number and exit 50 | ``` 51 | 52 | ```console 53 | $ ./todo remove daily 54 | Removed: daily 55 | ``` 56 | 57 | The final tree: 58 | 59 | ```console 60 | $ tree -I __pycache__ 61 | . 62 | ├── init.py 63 | ├── remove.py 64 | └── todo 65 | 66 | 0 directories, 3 files 67 | ``` 68 | 69 | The next command is `todo rename` which should rename our project. 70 | -------------------------------------------------------------------------------- /docs/todo-app/rename-project.md: -------------------------------------------------------------------------------- 1 | # todo rename command 2 | 3 | For renaming our project(database) we need the old name and a new name as function arguments to our `rename.py`. 4 | 5 | ```console 6 | $ touch rename.py 7 | $ tree 8 | . 9 | └── TODO 10 | ├── init.py 11 | ├── remove.py 12 | ├── rename.py 13 | └── todo 14 | 15 | 1 directory, 4 files 16 | ``` 17 | 18 | ```py title="rename.py" 19 | import os 20 | 21 | def rename(old_name: str, new_name: str) -> None: 22 | """ 23 | Rename the project name 24 | 25 | Args: 26 | old_name (str): old name of the project 27 | new_name (str): new name of the project 28 | 29 | Return: None 30 | """ 31 | os.rename(f"{old_name}.json", f"{new_name}.json") 32 | print(f"Renamed: {old_name} {new_name}") 33 | ``` 34 | 35 | Get the help: 36 | 37 | ```console 38 | $ ./todo rename -h 39 | usage: todo rename [-h] old_name new_name 40 | 41 | positional arguments: 42 | old_name old name of the project 43 | new_name new name of the project 44 | 45 | optional arguments: 46 | -h, --help show this help message and exit 47 | ``` 48 | 49 | 50 | Initializing: 51 | 52 | ```console 53 | $ ./todo init daily 54 | Created: daily.json 55 | ``` 56 | 57 | ```console 58 | $ tree -I __pycache__ 59 | . 60 | ├── init.py 61 | ├── daily.json 62 | ├── remove.py 63 | ├── rename.py 64 | └── todo 65 | 66 | 0 directories, 5 files 67 | ``` 68 | 69 | Renaming: 70 | 71 | ```console 72 | $ ./todo rename daily DAILY 73 | Renamed: daily DAILY 74 | ``` 75 | 76 | ```console 77 | $ tree -I __pycache__ 78 | . 79 | ├── init.py 80 | ├── DAILY.json 81 | ├── remove.py 82 | ├── rename.py 83 | └── todo 84 | 85 | 0 directories, 5 files 86 | ``` 87 | 88 | So far our TODO CLI has 3 features: 89 | 90 | ```console 91 | $ ./todo -h 92 | usage: todo [-h] [-v] {init,remove,rename} ... 93 | 94 | TODO CLI APP 95 | 96 | positional arguments: 97 | {init,remove,rename} 98 | init Initialize the .json file with given name 99 | remove Remove the .json file with given project name 100 | rename Rename the project name 101 | 102 | optional arguments: 103 | -h, --help show this help message and exit 104 | -v, --version show program's version number and exit 105 | ``` 106 | 107 | The next is to set up our task management commands. 108 | -------------------------------------------------------------------------------- /docs/todo-app/task/task-add.md: -------------------------------------------------------------------------------- 1 | # todo task add command 2 | 3 | As task management logically is a group of commands it is better to add them to the `task` package: 4 | 5 | ```console 6 | $ tree 7 | . 8 | └── TODO 9 | ├── init.py 10 | ├── remove.py 11 | ├── rename.py 12 | ├── task 13 | │   └── __init__.py 14 | ├── todo 15 | └── _todos 16 | ├── database.py 17 | ├── __init__.py 18 | └── todo.py 19 | 20 | 3 directories, 8 files 21 | ``` 22 | 23 | ```py title="__init__.py" 24 | """ 25 | Task management commands 26 | """ 27 | ``` 28 | 29 | Get the overall help: 30 | 31 | ```console 32 | $ ./todo -h 33 | usage: todo [-h] [-v] {init,remove,rename,task} ... 34 | 35 | TODO CLI APP 36 | 37 | positional arguments: 38 | {init,remove,rename,task} 39 | init Initialize the .json file with given name 40 | remove Remove the .json file with given project name 41 | rename Rename the project name 42 | task Task management commands 43 | 44 | optional arguments: 45 | -h, --help show this help message and exit 46 | -v, --version show program's version number and exit 47 | ``` 48 | 49 | **As you may notice the `_todos` package was ignored as it is considered as "non-public" - pure Python convention.** 50 | 51 | So, the DynaCLI does not interfere with any already existing codebase. 52 | 53 | To implement the task adding, we need to create the `add.py` file: 54 | 55 | ```console 56 | tree 57 | . 58 | └── TODO 59 | ├── init.py 60 | ├── remove.py 61 | ├── rename.py 62 | ├── task 63 | │   ├── add.py 64 | │   └── __init__.py 65 | ├── todo 66 | └── _todos 67 | ├── database.py 68 | ├── __init__.py 69 | └── todo.py 70 | 71 | 3 directories, 9 files 72 | ``` 73 | 74 | Here we use as a reference [Implement the add CLI Command](https://realpython.com/python-typer-cli/#implement-the-add-cli-command). 75 | 76 | And of course, define the add function in the `add.py`: 77 | 78 | ```py title="add.py" 79 | from _todos import todo 80 | 81 | 82 | def add(project_name: str, task: str, *tasks: str) -> None: 83 | """ 84 | Add a task to the project 85 | 86 | Args: 87 | project_name (str): the project name 88 | task (str): task name 89 | *tasks (str): variable length argument 90 | 91 | Return: None 92 | """ 93 | todo_ = todo.get_todoer(project_name) 94 | for t in [task, *tasks]: 95 | todo_.add(t) 96 | 97 | print("Success") 98 | ``` 99 | 100 | Next is to add the implementations of `add` and `add_multiple` methods in Todoer class: 101 | 102 | ```py title="_todos/todo.py" hl_lines="20 28" 103 | import os 104 | from .database import DatabaseHandler 105 | from typing import NamedTuple, Any 106 | from . import DB_READ_ERROR 107 | 108 | 109 | DIR = os.path.dirname(__file__) 110 | 111 | 112 | class CurrentTodo(NamedTuple): 113 | todo: dict[str, list[tuple[str]]] 114 | error: int 115 | 116 | 117 | class Todoer: 118 | def __init__(self, project_name: str) -> None: 119 | self.project_name = project_name 120 | self._db_handler = DatabaseHandler(DIR + f"/../{project_name}.json") 121 | 122 | def add(self, task: str) -> CurrentTodo: 123 | read = self._db_handler.read_todos() 124 | if read.error == DB_READ_ERROR: 125 | return CurrentTodo(read.todo_list, read.error) 126 | read.todo_list[self.project_name].append(["Todo", task]) 127 | write = self._db_handler.write_todos(read.todo_list) 128 | return CurrentTodo(write.todo_list, write.error) 129 | 130 | def add_multiple(self, tasks: tuple[str]) -> None: 131 | read = self._db_handler.read_todos() 132 | for task_ in tasks: 133 | read.todo_list[self.project_name].append(["Todo", task_]) 134 | self._db_handler.write_todos(read.todo_list) 135 | 136 | 137 | def get_todoer(project_name: str) -> Todoer: 138 | return Todoer(project_name) 139 | ``` 140 | 141 | Now let's test our CLI: 142 | 143 | ```console 144 | $ ./todo init daily 145 | Created: daily.json 146 | ``` 147 | 148 | Adding 2 daily tasks: 149 | 150 | ```console 151 | 152 | $ ./todo task add daily "morning walk" 153 | Success 154 | 155 | $ ./todo task add daily "night walk" 156 | Success 157 | ``` 158 | 159 | Now the daily.json file looks like: `{"daily": [["Todo", "morning walk"], ["Todo", "night walk"]]}`. 160 | 161 | How about adding multiple tasks in one shot? 162 | 163 | Adding multiple daily tasks: 164 | 165 | ```console 166 | $ ./todo task add daily gym "eat vegetables" "eat fruits" 167 | Success 168 | ``` 169 | 170 | If you check the `daily.json`: `{"daily": [["Todo", "morning walk"], ["Todo", "night walk"], ["Todo", "gym"], ["Todo", "eat vegetables"], ["Todo", "eat fruits"]]}` 171 | 172 | If you have already noticed every task is by default marked as `"Todo"`. 173 | 174 | The next topic is to add the `todo task list` command. 175 | -------------------------------------------------------------------------------- /docs/todo-app/task/task-clear.md: -------------------------------------------------------------------------------- 1 | # todo task clear command 2 | 3 | How about removing all tasks? I.E clearing the project? 4 | 5 | The idea is similar to the [Implement the clear CLI Command](https://realpython.com/python-typer-cli/#implement-the-clear-cli-command) 6 | 7 | First, we need to update the Todoer controller: 8 | 9 | ```py title="_todos/todo.py" 10 | ... 11 | 12 | class Todoer: 13 | 14 | ... 15 | 16 | def remove_all(self) -> CurrentTodo: 17 | """Remove all to-dos from the database.""" 18 | write = self._db_handler.write_todos({f"{self.project_name}": []}) 19 | return CurrentTodo({}, write.error) 20 | 21 | ... 22 | ``` 23 | 24 | The next thing is to create the `clear.py` file in the `task` package: 25 | 26 | ```bash 27 | $ tree 28 | . 29 | └── TODO 30 | ├── init.py 31 | ├── remove.py 32 | ├── rename.py 33 | ├── task 34 | │   ├── add.py 35 | │   ├── clear.py 36 | │   ├── complete.py 37 | │   ├── delete.py 38 | │   ├── __init__.py 39 | │   └── list.py 40 | ├── todo 41 | └── _todos 42 | ├── database.py 43 | ├── __init__.py 44 | └── todo.py 45 | 46 | 3 directories, 13 files 47 | ``` 48 | 49 | 50 | The actual implementation is similar to the original blog post, here we are intentionally using a decorator as a prompt: 51 | 52 | ```py title="task/clear.py" 53 | from functools import wraps 54 | from _todos import todo 55 | 56 | 57 | def _prompt(func_: callable) -> callable: 58 | @wraps(func_) 59 | def wrapper(project_name: str): 60 | while True: 61 | choice = input("Delete all to-dos? [y/N]:") 62 | if 'y' == choice: 63 | return func_(project_name) 64 | elif 'N' == choice: 65 | print('Operation cancelled') 66 | exit(1) 67 | print('Invalid choice. Try again') 68 | 69 | return wrapper 70 | 71 | 72 | @_prompt 73 | def clear(project_name: str) -> None: 74 | """ 75 | Deleting all tasks 76 | 77 | Args: 78 | project_name (str): the project name 79 | 80 | Return: None 81 | """ 82 | todo_ = todo.get_todoer(project_name) 83 | todo_.remove_all() 84 | 85 | print("All to-dos were removed") 86 | 87 | ``` 88 | 89 | Get the help of the `clear` command: 90 | 91 | ```console 92 | $ ./todo task clear -h 93 | usage: todo task clear [-h] project_name 94 | 95 | positional arguments: 96 | project_name the project name 97 | 98 | optional arguments: 99 | -h, --help show this help message and exit 100 | ``` 101 | 102 | **As you see, the actual code and also the `DynaCLI` implementation did not interfere with the `_prompt` decorator.** 103 | 104 | Let's test our clear command: 105 | 106 | ```console 107 | $ ./todo task list daily 108 | ID. Is Done | Description 109 | 1 > morning walk 110 | 2 X night walk 111 | 3 X gym 112 | 4 X eat vegetables 113 | ``` 114 | 115 | ```console 116 | $ ./todo task clear daily 117 | Delete all to-dos? [y/N]:N 118 | Operation canceled 119 | ``` 120 | 121 | ```console 122 | ./todo task clear daily 123 | Delete all to-dos? [y/N]:sasd 124 | Invalid choice. Try again 125 | Delete all to-dos? [y/N]:y 126 | All to-dos were removed 127 | ``` 128 | 129 | ```console 130 | $ /todo task list daily 131 | ID. Is Done | Description 132 | ``` 133 | 134 | Dead simple. `DynaCLI` just converted `clear` function to the CLI `clear` command(yes it works with **decorated** functions). 135 | -------------------------------------------------------------------------------- /docs/todo-app/task/task-delete.md: -------------------------------------------------------------------------------- 1 | # todo task delete command 2 | 3 | We should be able to delete a given task from a given project. 4 | Let's implement this command as well. You need to create the `delete.py` file: 5 | 6 | ```console 7 | $ tree 8 | . 9 | └── TODO 10 | ├── init.py 11 | ├── remove.py 12 | ├── rename.py 13 | ├── task 14 | │   ├── add.py 15 | │   ├── delete.py 16 | │   ├── __init__.py 17 | │   └── list.py 18 | ├── todo 19 | └── _todos 20 | ├── database.py 21 | ├── __init__.py 22 | └── todo.py 23 | 24 | 3 directories, 11 files 25 | ``` 26 | 27 | Next, we need to add delete functionality to our Todoer controller. 28 | 29 | The following code portion is from [Implement the remove CLI Command](https://realpython.com/python-typer-cli/#implement-the-remove-cli-command) 30 | 31 | ```py title="_todos/todo.py" 32 | 33 | ... 34 | 35 | class Todoer: 36 | ... 37 | 38 | def delete(self, task_id: int) -> CurrentTodo: 39 | """Delete a to-do from the database using its id or index.""" 40 | read = self._db_handler.read_todos() 41 | if read.error: 42 | return CurrentTodo({}, read.error) 43 | try: 44 | read.todo_list[self.project_name].pop(task_id - 1) 45 | except IndexError: 46 | return CurrentTodo({}, ID_ERROR) 47 | write = self._db_handler.write_todos(read.todo_list) 48 | return CurrentTodo(write.todo_list, write.error) 49 | 50 | ... 51 | 52 | ``` 53 | 54 | Add the actual delete command: 55 | 56 | ```py title="delete.py" 57 | from _todos import todo 58 | 59 | 60 | def delete(project_name: str, task_id: int) -> None: 61 | """ 62 | Delete given task from the project 63 | 64 | Args: 65 | project_name (str): the project name 66 | task_id (str): the task id to be removed 67 | 68 | Return: None 69 | """ 70 | todo_ = todo.get_todoer(project_name) 71 | todo_.delete(task_id) 72 | print("Success") 73 | 74 | ``` 75 | 76 | Let's test our delete command: 77 | 78 | ```console 79 | $ ./todo task list daily 80 | ID. Is Done | Description 81 | 1 X morning walk 82 | 2 X night walk 83 | 3 X gym 84 | 4 X eat vegetables 85 | 5 X eat fruits 86 | ``` 87 | 88 | Removing night walk from our daily routine(not in real life): 89 | 90 | ```console 91 | $ ./todo task delete daily 5 92 | Success 93 | ``` 94 | 95 | List tasks again: 96 | 97 | ```console 98 | $ ./todo task list daily 99 | ID. Is Done | Description 100 | 1 X morning walk 101 | 2 X night walk 102 | 3 X gym 103 | 4 X eat vegetables 104 | ``` 105 | 106 | Again, as you have already noticed everything is dead simple and CLI depends on what you wrote in pure Python, translating arguments to CLI arguments. 107 | As a result, you don't have to write extra CLI command code - every function is already a command. 108 | -------------------------------------------------------------------------------- /docs/todo-app/task/task-done.md: -------------------------------------------------------------------------------- 1 | # todo task complete command 2 | 3 | As described in the original blog post ([Step 6](https://realpython.com/python-typer-cli/#step-6-code-the-to-do-completion-functionality)) 4 | we need to add complete command to mark the task as done by the given ID. 5 | 6 | First, we need to update the Todoer controller: 7 | 8 | ```py title="_todos/todo.py" 9 | 10 | ... 11 | 12 | class Todoer: 13 | ... 14 | 15 | def set_done(self, todo_id: int) -> CurrentTodo: 16 | """Set a to-do as done.""" 17 | read = self._db_handler.read_todos() 18 | if read.error: 19 | return CurrentTodo({}, read.error) 20 | try: 21 | todo = read.todo_list[self.project_name][todo_id - 1] 22 | except IndexError: 23 | return CurrentTodo({}, ID_ERROR) 24 | todo[0] = "Done" 25 | write = self._db_handler.write_todos(read.todo_list) 26 | return CurrentTodo(write.todo_list, write.error) 27 | 28 | ... 29 | ``` 30 | 31 | The next thing is to create the `complete.py` file in the `task` package: 32 | 33 | ```console 34 | $ tree 35 | . 36 | └── TODO 37 | ├── init.py 38 | ├── remove.py 39 | ├── rename.py 40 | ├── task 41 | │   ├── add.py 42 | │   ├── complete.py 43 | │   ├── delete.py 44 | │   ├── __init__.py 45 | │   └── list.py 46 | ├── todo 47 | └── _todos 48 | ├── database.py 49 | ├── __init__.py 50 | └── todo.py 51 | 52 | 3 directories, 12 files 53 | 54 | ``` 55 | 56 | Let's write our complete function: 57 | 58 | ```py title="task/complete.py" 59 | from _todos import todo 60 | 61 | 62 | def complete(project_name: str, task_id: int) -> None: 63 | """ 64 | Set to done given task. Mark as complete. 65 | 66 | Args: 67 | project_name (str): the project name 68 | task_id (str): the task id to be removed 69 | 70 | Return: None 71 | """ 72 | todo_ = todo.get_todoer(project_name) 73 | todo_.set_done(task_id) 74 | print("Success") 75 | 76 | ``` 77 | 78 | That's it, again no need for registering your command to CLI: it is already considered as CLI command. 79 | 80 | Currently, we have 4 task commands: 81 | 82 | ```console 83 | $ /todo task -h 84 | usage: todo task [-h] {add,complete,delete,list} ... 85 | 86 | positional arguments: 87 | {add,complete,delete,list} 88 | add Add task to the project 89 | complete Set to done given task. Mark as complete. 90 | delete Delete given task from the project 91 | list Show all tasks in the given project 92 | 93 | optional arguments: 94 | -h, --help show this help message and exit 95 | ``` 96 | 97 | Run the command: 98 | 99 | ```console 100 | $ ./todo task list daily 101 | ID. Is Done | Description 102 | 1 X morning walk 103 | 2 X night walk 104 | 3 X gym 105 | 4 X eat vegetables 106 | ``` 107 | 108 | ```console 109 | $ ./todo task complete daily 1 110 | Success 111 | ``` 112 | 113 | ```console hl_lines="3" 114 | $ ./todo task list daily 115 | ID. Is Done | Description 116 | 1 > morning walk 117 | 2 X night walk 118 | 3 X gym 119 | 4 X eat vegetables 120 | ``` 121 | 122 | As you have already noticed, the status has been changed from "X" to ">" marking it as a Done. 123 | 124 | In raw `daily.json` file it is updated as well: 125 | 126 | `{"daily": [["Done", "morning walk"], ["Todo", "night walk"], ["Todo", "gym"], ["Todo", "eat vegetables"]]}` 127 | -------------------------------------------------------------------------------- /docs/todo-app/task/task-list.md: -------------------------------------------------------------------------------- 1 | # todo task list command 2 | 3 | It should be easier to get back registered tasks back using CLI, rather than looking at `.json` files. 4 | So we are going to add simple command to list available tasks in the given project. 5 | We have changed the implementation described here: [Implement the list Command](https://realpython.com/python-typer-cli/#implement-the-list-command) 6 | 7 | Create `list.py` file: 8 | 9 | ```console 10 | $ tree 11 | . 12 | └── TODO 13 | ├── init.py 14 | ├── remove.py 15 | ├── rename.py 16 | ├── task 17 | │   ├── add.py 18 | │   ├── __init__.py 19 | │   └── list.py 20 | ├── todo 21 | └── _todos 22 | ├── database.py 23 | ├── __init__.py 24 | └── todo.py 25 | 26 | 3 directories, 10 files 27 | ``` 28 | 29 | Implementation: 30 | 31 | ```py title="list.py" 32 | import os 33 | from _todos import todo 34 | 35 | 36 | def list(project_name: str) -> None: 37 | """ 38 | Show all tasks in the given project 39 | 40 | Args: 41 | project_name (str): the project name 42 | 43 | Return: None 44 | """ 45 | todo_ = todo.get_todoer(project_name) 46 | todo_list = todo_.get_todo_list() 47 | _format_output(todo_list) 48 | 49 | 50 | def _format_output(stdout: list[list[str, any]]) -> None: 51 | headers = ("ID. ", "Is Done ", "| Description") 52 | print("".join(headers)) 53 | for id_, t in enumerate(stdout, 1): 54 | status = "X" if t[0] == 'Todo' else ">" 55 | print(id_, status, t[1]) 56 | ``` 57 | 58 | Now, we need to add the `get_todo_list()` method to the Todoer class: 59 | 60 | ```py title="_todos/todo.py" hl_lines="7" 61 | 62 | ... 63 | 64 | class Todoer: 65 | 66 | ... 67 | 68 | def get_todo_list(self) -> list[list[str, Any]]: 69 | """Return the current to-do list.""" 70 | read = self._db_handler.read_todos() 71 | return read.todo_list[self.project_name] 72 | 73 | 74 | ... 75 | 76 | ``` 77 | 78 | Getting help and running the command: 79 | 80 | ```console 81 | $ ./todo task list -h 82 | usage: todo task list [-h] project_name 83 | 84 | positional arguments: 85 | project_name the project name 86 | 87 | optional arguments: 88 | -h, --help show this help message and exit 89 | ``` 90 | 91 | **If you have noticed, the `_format_output()` function was not considered as a command - as it is a "non-public" function based on Python convention.** 92 | 93 | Let's run the actual command: 94 | 95 | ```console 96 | $ ./todo task list daily 97 | ID. Is Done | Description 98 | 1 X morning walk 99 | 2 X night walk 100 | 3 X gym 101 | 4 X eat vegetables 102 | 5 X eat fruits 103 | ``` 104 | 105 | How about adding separate versions to our commands? It is possible to have different commands from various resources, and they can have different versioning. 106 | It is easy to implement it with DynaCLI, just add `__version__` to the `list.py` file: 107 | 108 | ```py title="list.py" hl_lines="6" 109 | import os 110 | from _todos import todo 111 | 112 | __version__ = "1.1" 113 | ``` 114 | 115 | Checking versions: 116 | 117 | ```console 118 | $ ./todo --version 119 | todo - v1.0 120 | ``` 121 | 122 | ```console 123 | $ ./todo task list --version 124 | todo task list - v1.1 125 | ``` 126 | 127 | So your main CLI and your commands can have different versions. 128 | 129 | The next is to add a command for deleting the task. 130 | -------------------------------------------------------------------------------- /docs/todo-app/task/todo-app-database-handler.md: -------------------------------------------------------------------------------- 1 | # Setup the database operations 2 | 3 | Here we grab [Step 2](https://realpython.com/python-typer-cli/#step-2-set-up-the-to-do-cli-app-with-python-and-typer) and [Step 4](https://realpython.com/python-typer-cli/#step-4-set-up-the-to-do-app-back-end 4 | ) from the original article and mostly ignored other code portions. 5 | 6 | As we have several helper codes we can store them in the `_todos` package: 7 | 8 | ```console 9 | $ tree 10 | . 11 | └── TODO 12 | ├── init.py 13 | ├── remove.py 14 | ├── rename.py 15 | ├── todo 16 | └── _todos 17 | ├── database.py 18 | └── __init__.py 19 | 20 | 2 directories, 6 files 21 | 22 | ``` 23 | 24 | Let's add some preliminary constants: 25 | 26 | 27 | ```py title="__init__.py" 28 | ( 29 | SUCCESS, 30 | DIR_ERROR, 31 | FILE_ERROR, 32 | DB_READ_ERROR, 33 | DB_WRITE_ERROR, 34 | JSON_ERROR, 35 | ID_ERROR, 36 | ) = range(7) 37 | 38 | ERRORS = { 39 | DIR_ERROR: "config directory error", 40 | FILE_ERROR: "config file error", 41 | DB_READ_ERROR: "database read error", 42 | DB_WRITE_ERROR: "database write error", 43 | ID_ERROR: "to-do id error", 44 | } 45 | ``` 46 | 47 | As you may notice we have removed redundant app name and version information from the `__init__.py` which was described in [Step 2](https://realpython.com/python-typer-cli/#step-2-set-up-the-to-do-cli-app-with-python-and-typer). 48 | 49 | Let's add our database handler class: 50 | 51 | ```py title="database.py" 52 | import json 53 | from typing import NamedTuple, Any 54 | from . import JSON_ERROR, SUCCESS, DB_READ_ERROR, DB_WRITE_ERROR 55 | 56 | 57 | class DBResponse(NamedTuple): 58 | todo_list: dict[str, list[list[str, Any]]] 59 | error: int 60 | 61 | 62 | class DatabaseHandler: 63 | 64 | def __init__(self, db_path: str) -> None: 65 | self._db_path = db_path 66 | 67 | def read_todos(self) -> DBResponse: 68 | try: 69 | with open(self._db_path, "r") as db: 70 | try: 71 | return DBResponse(json.loads(db.readline()), SUCCESS) 72 | except json.JSONDecodeError: # Catch wrong JSON format 73 | return DBResponse({}, JSON_ERROR) 74 | except OSError: # Catch file IO problems 75 | return DBResponse({}, DB_READ_ERROR) 76 | 77 | def write_todos(self, todo_list: dict[str, list[list[str, Any]]]) -> DBResponse: 78 | try: 79 | with open(self._db_path, "w") as db: 80 | json.dump(todo_list, db) 81 | return DBResponse(todo_list, SUCCESS) 82 | except OSError: # Catch file IO problems 83 | return DBResponse(todo_list, DB_WRITE_ERROR) 84 | ``` 85 | 86 | Again, we have slightly changed the code but most of it is from [Step 4](https://realpython.com/python-typer-cli/#step-4-set-up-the-to-do-app-back-end). 87 | 88 | We added an extra package to our CLI path, it should be broken right now? Of course not. 89 | 90 | In pure Python convention, the names which are started with `_`(underscore) are considered "non-public". 91 | DynaCLI follows this convention, and we just **ignore "non-public" packages** - they are not considered as part of CLI. 92 | 93 | The next is to add a Controller class for our TODOs. 94 | -------------------------------------------------------------------------------- /docs/todo-app/task/todo-controller-class.md: -------------------------------------------------------------------------------- 1 | # Setup todo controller class 2 | 3 | This section is primarily adopted from [Step 4](https://realpython.com/python-typer-cli/#step-4-set-up-the-to-do-app-back-end) and [Step 5](https://realpython.com/python-typer-cli/#step-5-code-the-adding-and-listing-to-dos-functionalities) 4 | 5 | Again we have omitted redundant parts and kept only needed code portions. 6 | 7 | Let's create the `todo.py` file in our `_todos` package: 8 | 9 | ```console 10 | $ tree 11 | . 12 | └── TODO 13 | ├── init.py 14 | ├── remove.py 15 | ├── rename.py 16 | ├── todo 17 | └── _todos 18 | ├── database.py 19 | ├── __init__.py 20 | └── todo.py 21 | 22 | 2 directories, 7 files 23 | ``` 24 | 25 | And add our controller class: 26 | 27 | ```py title="todo.py" 28 | import os 29 | from .database import DatabaseHandler 30 | from typing import NamedTuple, Any 31 | from . import DB_READ_ERROR, ID_ERROR 32 | 33 | DIR = os.path.dirname(__file__) 34 | 35 | 36 | class CurrentTodo(NamedTuple): 37 | todo: dict[str, list[list[str, Any]]] 38 | error: int 39 | 40 | 41 | class Todoer: 42 | def __init__(self, project_name: str) -> None: 43 | self.project_name = project_name 44 | self._db_handler = DatabaseHandler(DIR + f"/../{project_name}.json") 45 | 46 | 47 | def get_todoer(project_name: str) -> Todoer: 48 | return Todoer(project_name) 49 | ``` 50 | 51 | We have added the `get_todoer` function to get back the Todoer object - it will be used in the actual CLI commands. 52 | 53 | The next is to implement the task-adding CLI command. 54 | -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | # Python types 2 | 3 | If you need a refresher about how to use Python type hints, read here [Python Type Checking (Guide)](https://realpython.com/python-type-checking/). 4 | 5 | You can also check the [mypy cheat sheet](https://mypy.readthedocs.io/en/latest/cheat_sheet_py3.html). 6 | In short (very short), you can declare a function with parameters like: 7 | 8 | ```Python 9 | from enum import Enum 10 | 11 | class Color(Enum): 12 | WHITE = 1 13 | RED = 2 14 | 15 | def type_example(name: str, formal: bool, exit: int, amount: float, color: Color, *args: str, **kwargs: int): 16 | pass 17 | ``` 18 | 19 | And your editor (and **DynaCLI**) will know that: 20 | 21 | * `name` is type of `str` and is a required parameter. 22 | * `formal` is type of `bool` and is a required parameter. 23 | * `exit` is type of `int` and is a required parameter. 24 | * `amount` is type of `float` and is a required parameter. 25 | * `color` is type of `Color` and is a required parameter. 26 | * `*args` variable length arguments with type of `str`. 27 | * `**kwargs` keyword arguments with type of `int`. 28 | 29 | These type hints are what give you autocomplete in your editor and several other features. 30 | 31 | **DynaCLI** is based on these type hints. 32 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: DynaCLI 2 | site_description: DynaCLI, turn your Python packages, modules into modular CLI app. Based on Python type hints. 3 | site_url: "" 4 | theme: 5 | name: material 6 | palette: 7 | primary: "deep blue" 8 | accent: "indigo" 9 | features: 10 | - content.code.annotate 11 | 12 | nav: 13 | - DynaCLI: "index.md" 14 | - Python Types Intro: "types.md" 15 | - Tutorial - TODO app: 16 | - TODO - Intro: "todo-app/index.md" 17 | - Init the database: "todo-app/init.md" 18 | - Remove the database: "todo-app/remove-project.md" 19 | - Rename the database: "todo-app/rename-project.md" 20 | - Task management: 21 | - Setup TODO app database handler: "todo-app/task/todo-app-database-handler.md" 22 | - Setup TODO app todo controller class: "todo-app/task/todo-controller-class.md" 23 | - Add the tasks: "todo-app/task/task-add.md" 24 | - List the tasks: "todo-app/task/task-list.md" 25 | - Delete single task: "todo-app/task/task-delete.md" 26 | - Clear all tasks: "todo-app/task/task-clear.md" 27 | - Set done the task: "todo-app/task/task-done.md" 28 | - Reference Manual: 29 | - DynaCLI installation: "manual/index.md" 30 | - CLI entrypoint: "manual/cli-entrypoint.md" 31 | - Top level command: "manual/top-level-command.md" 32 | - Package as feature: "manual/package-as-feature.md" 33 | - Module as feature: "manual/module-as-feature.md" 34 | - Advanced Reference Manual: 35 | - Why DynaCLI?: "advanced/why.md" 36 | - Supported types: "advanced/types.md" 37 | - Docstring formats: "advanced/docstrings.md" 38 | - Search Path manipulation: "advanced/search-path.md" 39 | - State Machine: "advanced/state-machine.md" 40 | 41 | markdown_extensions: 42 | - pymdownx.highlight: 43 | anchor_linenums: true 44 | - pymdownx.inlinehilite 45 | - pymdownx.snippets 46 | - pymdownx.superfences 47 | - pymdownx.details 48 | - admonition 49 | - attr_list 50 | - md_in_html 51 | - toc: 52 | toc_depth: "1-1" 53 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | # --strict 4 | disallow_any_generics = True 5 | disallow_subclassing_any = True 6 | disallow_untyped_calls = True 7 | disallow_untyped_defs = True 8 | disallow_incomplete_defs = True 9 | check_untyped_defs = True 10 | disallow_untyped_decorators = True 11 | no_implicit_optional = True 12 | warn_redundant_casts = True 13 | warn_unused_ignores = True 14 | warn_return_any = True 15 | implicit_reexport = False 16 | strict_equality = True 17 | # --strict end -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "dynacli" 7 | authors = [{name = "BST Labs", email = "bstlabs@caios.io"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE.md"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | requires-python = ">=3.8" 13 | 14 | 15 | [project.urls] 16 | Documentation = "https://bstlabs.github.io/py-dynacli/" 17 | Source = "https://github.com/bstlabs/py-dynacli" 18 | Home = "https://bstlabs.github.io/py-dynacli" 19 | 20 | [project.optional-dependencies] 21 | doc = ["mkdocs-material >=8.1.2"] 22 | dev = [ 23 | "black >=22.3.0", 24 | "pylint >=2.12.2", 25 | "isort >=5.9.3", 26 | "autoflake >=1.4", 27 | "flake8 >=4.0.1", 28 | "pre-commit >=2.17.0" 29 | ] 30 | 31 | [project.scripts] 32 | dynacli = "dynacli.bootstrap._cli:main" 33 | 34 | [tool.isort] 35 | profile = "black" 36 | py_version = 39 37 | skip = [".gitignore", ".dockerignore"] 38 | extend_skip = [".md", ".json"] 39 | skip_glob = ["docs/*"] 40 | 41 | [tool.black] 42 | line-length = 88 43 | target-version = ['py39'] 44 | include = '\.pyi?$' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | autoflake==1.4 2 | black==21.9b0 3 | click==8.0.3 4 | flake8==4.0.1 5 | isort==5.9.3 6 | mccabe==0.6.1 7 | mypy==0.910 8 | mypy-extensions==0.4.3 9 | pathspec==0.9.0 10 | platformdirs==2.4.0 11 | pycodestyle==2.8.0 12 | pyflakes==2.4.0 13 | regex==2021.10.23 14 | toml==0.10.2 15 | tomli==1.2.2 16 | typing-extensions==3.10.0.2 17 | flit==3.6.0 18 | flit_core==3.6.0 -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf ../build 4 | rm -rf ../dist 5 | rm -rf ../open_cli.egg-info 6 | 7 | pip3 uninstall dynacli -y -------------------------------------------------------------------------------- /scripts/format-imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | # Sort imports one per line, so autoflake can remove unused imports 5 | isort src test --force-single-line-imports 6 | sh ./scripts/format.sh -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place src --exclude=__init__.py 5 | black src test 6 | isort src test -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | flake8 src 7 | black src test --check --diff 8 | isort src test --check --diff -------------------------------------------------------------------------------- /src/dynacli/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Convert your Python functions into CLI commands 3 | """ 4 | 5 | from .dynacli import main 6 | 7 | __version__ = "1.0.9b0" 8 | -------------------------------------------------------------------------------- /src/dynacli/bootstrap/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/src/dynacli/bootstrap/__init__.py -------------------------------------------------------------------------------- /src/dynacli/bootstrap/_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | DynaCLI bootstrap script 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | from dynacli import main as dynamain 11 | 12 | cwd = os.path.dirname(os.path.realpath(__file__)) 13 | 14 | search_path = [cwd] 15 | sys.path.extend(search_path) 16 | 17 | 18 | # This fix is for dynacli entrypoint script; as it has wrapper __main__ we need to add necessary information 19 | 20 | _map = { 21 | "__version__": "1.0.9b0", 22 | "__doc__": """ 23 | DynaCLI bootstrap script 24 | """, 25 | } 26 | 27 | 28 | def _set_main_attrs(**kwargs): 29 | _main = sys.modules["__main__"] 30 | for key, val in kwargs.items(): 31 | setattr(_main, key, val) 32 | 33 | 34 | # For package distro purposes 35 | def main(): 36 | _set_main_attrs(**_map) 37 | dynamain(search_path) 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /src/dynacli/bootstrap/_sample_cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | DynaCLI bootstrap script # Change me 5 | """ 6 | 7 | 8 | import os 9 | import sys 10 | from typing import Final 11 | 12 | from dynacli import main 13 | 14 | cwd = os.path.dirname(os.path.realpath(__file__)) 15 | 16 | __version__: Final[str] = "0.0.0" # Change me to define your own version 17 | 18 | 19 | search_path = [cwd] # Change me if you have different path; you can add multiple search pathes 20 | sys.path.extend(search_path) 21 | # root_packages = ['cli.dev', 'cli.admin'] # Change me if you have predefined root package name 22 | # main(search_path, root_packages) # Uncomment if you have root_packages defined 23 | 24 | main(search_path) 25 | -------------------------------------------------------------------------------- /src/dynacli/bootstrap/init.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import stat 4 | 5 | 6 | def init(name: str, **kwargs: str) -> None: 7 | """ 8 | Initialize the CLI entrypoint in given path 9 | 10 | Args: 11 | name (str): name of the new CLI tool 12 | **kwargs (str): The keyword arguments for CLI entrypoint 13 | 14 | Return: None 15 | """ 16 | _wd = os.path.dirname(os.path.realpath(__file__)) 17 | _path = kwargs.get("path") 18 | _cwd = _path if _path and _path != "." else os.path.curdir 19 | try: 20 | shutil.copyfile(f"{_wd}/_sample_cli", f"{_cwd}/{name}") 21 | os.chmod(f"{_cwd}/{name}", stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) 22 | except Exception as err: 23 | print("Failed to create CLI entrypoint with ", str(err)) 24 | else: 25 | print(f"Successfully created CLI entrypoint {name} at {os.path.abspath(_cwd)}") 26 | -------------------------------------------------------------------------------- /src/dynacli/dynacli.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from argparse import Action, ArgumentError, ArgumentParser 4 | from enum import Enum, EnumMeta 5 | from functools import partial 6 | from importlib import import_module 7 | from inspect import Parameter, signature 8 | from itertools import product 9 | from os import path 10 | from pkgutil import iter_modules 11 | from types import MappingProxyType, ModuleType 12 | from typing import ( 13 | Any, 14 | AnyStr, 15 | Callable, 16 | Dict, 17 | Final, 18 | Iterator, 19 | List, 20 | Match, 21 | Optional, 22 | Pattern, 23 | Tuple, 24 | Type, 25 | TypedDict, 26 | Union, 27 | ) 28 | 29 | ARG_PATTERN: Final[Pattern[str]] = re.compile(r"\s*(.+)\s+\(.+\):\s+(.+)$") 30 | 31 | 32 | try: 33 | ChoicesType = Optional[MappingProxyType[Any, EnumMeta]] 34 | except TypeError: # Python 3.8 failing here for some unknown reason 35 | ChoicesType = Optional[MappingProxyType] 36 | 37 | 38 | class _KwargsAction(Action): 39 | """**kwargs argument parsing action""" 40 | 41 | def __call__(self, parser, namespace, values, option_string=None): # type: ignore 42 | dict_ = dict(values) 43 | setattr(namespace, self.dest, dict_) 44 | 45 | 46 | class ArgProps(TypedDict): 47 | """Argument properties""" 48 | 49 | dest: str 50 | nargs: Optional[Union[int, str]] 51 | choices: Optional[List[str]] 52 | type: Union[type, Callable] 53 | action: Union[str, Callable] 54 | 55 | 56 | def _choices_patch(): 57 | # This is a monkey patch for showing proper error message for nargs="*" + choices 58 | # The main goal to support Enum type support with *args and **kwargs 59 | def _check_value(self, action, value): 60 | # converted value must be one of the choices (if specified) 61 | value = value[-1] if isinstance(value, tuple) else value 62 | if action.choices is not None and value not in action.choices: 63 | choices = ( 64 | [choice for choice in action.choices if choice] 65 | if isinstance(action.choices, list) 66 | else action.choices 67 | ) 68 | args = { 69 | "value": value, 70 | "choices": ", ".join(map(repr, choices)), 71 | } 72 | msg = "invalid choice: %(value)r (choose from %(choices)s)" 73 | raise ArgumentError(action, msg % args) 74 | 75 | return _check_value 76 | 77 | 78 | def _get_feature_help(module: object) -> str: 79 | """ 80 | Get the docstring of the imported module 81 | :param module: the imported module object 82 | :return: The docstring as string 83 | """ 84 | return module.__doc__ or "[ERROR] Missing the module docstring" 85 | 86 | 87 | def _parse_command_doc(command: Callable) -> Tuple[str, Optional[str]]: 88 | """ 89 | The function for parsing function docstring 90 | :param command: the actual function object 91 | :return: the description and spec from docstring 92 | """ 93 | if not command.__doc__: 94 | return "[ERROR] Missing command docstring", None 95 | description, _, spec = command.__doc__.partition("Args:") 96 | return description, spec 97 | 98 | 99 | def _get_command_description(command: Callable) -> str: 100 | description, _ = _parse_command_doc(command) 101 | return description 102 | 103 | 104 | def _get_command_help(name: str, module: object) -> str: 105 | """ 106 | Get the function docstring from imported module object 107 | :param name: name of the function 108 | :param module: the imported module object 109 | :return: the description string 110 | """ 111 | return _get_command_description(module.__dict__[name]) 112 | 113 | 114 | def _is_package(module: ModuleType) -> Optional[bool]: 115 | if hasattr(module, "__file__"): 116 | return module.__file__.endswith("__init__.py") 117 | 118 | 119 | def _is_module_shortcut(name: str, package: ModuleType) -> bool: 120 | return ( 121 | name in package.__dict__ 122 | and not _is_package(package.__dict__[name]) 123 | and _is_feature_module(name, package.__dict__[name]) 124 | ) 125 | 126 | 127 | def _is_feature_module(name: str, module: ModuleType) -> bool: 128 | return name not in module.__dict__ 129 | 130 | 131 | def _is_command(name: str, module: ModuleType) -> bool: 132 | return not (_is_package(module) or _is_feature_module(name, module)) 133 | 134 | 135 | def _is_public(name: str) -> bool: 136 | return not name.startswith("_") 137 | 138 | 139 | def _is_origin_from_search_path(name: str, search_path: List[str]) -> bool: 140 | return any(name.startswith(path_) for path_ in search_path) 141 | 142 | 143 | def _is_from_public_path(name: str) -> bool: 144 | return all(not _name.startswith("_") for _name in name.split(".")) 145 | 146 | 147 | def _is_callable(command: object) -> bool: 148 | return isinstance(command, Callable) 149 | 150 | 151 | def _is_package_command(name: str, package: ModuleType) -> bool: 152 | return name in package.__dict__ and _is_callable(package.__dict__[name]) 153 | 154 | 155 | def _get_module_help(name: str, module: ModuleType) -> str: 156 | return ( 157 | _get_command_help(name, module) 158 | if _is_command(name, module) 159 | else _get_feature_help(module) 160 | ) 161 | 162 | 163 | def _execute_command(args: Dict[str, Any], func_: Callable) -> None: 164 | """ 165 | Here we are running actual function with positional arguments. 166 | If the function signature has **kwargs we will get keyword arguments 167 | and pass to the function accordingly. 168 | :param args: argument dictionary from argparse 169 | :param func_: function object to be called 170 | :return: 171 | """ 172 | pos_values_ = args.get("pos_args", []) 173 | kwargs_ = args.get("kwargs") 174 | func_(*pos_values_, **kwargs_) if kwargs_ else func_(*pos_values_) 175 | 176 | 177 | def _process_type(type_: type) -> Tuple[Union[type, Callable], ChoicesType]: 178 | """ 179 | Function for processing int, str, float, bool and Enum type. 180 | Currently, we are supporting only these types. 181 | :param type_: the type name. 182 | :return: return the tuple of the types. 183 | """ 184 | try: 185 | if type_ in {int, str, float, bool}: 186 | return type_, None 187 | elif issubclass(type_, Enum): 188 | return str, getattr(type_, "__members__", {}) 189 | except TypeError: # Python quirks with Optional etc.gg 190 | pass 191 | raise ValueError(f"Unsupported argument type {type_}") 192 | 193 | 194 | def _calc_n_kwargs(args: List[str]) -> Union[str, int]: 195 | """ 196 | Here we try to calculate the nargs for **kwargs arguments. 197 | Basically we are starting from the end until not encountering 198 | the "=" sign in the provided argument. 199 | Example: [arguments: pos[bst labs] *args[lib1 lib2] **kwargs[name1=lib1 name2=lib2]] 200 | :param args: the list of arguments from the argparse 201 | :return: nargs for argparse add_argument() 202 | """ 203 | n_kwargs = 0 204 | for n_kwargs in range(0, -len(args), -1): 205 | if "=" not in args[n_kwargs - 1]: 206 | break 207 | return -n_kwargs or "*" 208 | 209 | 210 | def _get_kwargs_arg_props( 211 | param_type: type, args: List[str], nargs: Union[str, int, None] 212 | ) -> ArgProps: 213 | """ 214 | The function for calculating the nargs of argparse.add_argument(); 215 | We will use it for understanding the count of the CLI arguments, 216 | and getting them as keyword arguments(**kwargs). 217 | :param param_type: the argument type 218 | :param args: list of the arguments from CLI 219 | :param nargs: the previous nargs to check if **kwargs was preceded by *args or not 220 | :return: 221 | """ 222 | # If previous nargs is None it means there is no *args at function signature, 223 | # and we can return "*" for nargs; 224 | # of the **kwargs. example: def func_(a: int, b: str, **kwargs: str) 225 | _, choices = _process_type(param_type) 226 | nargs = _calc_n_kwargs(args) if nargs == "*" else "*" 227 | 228 | def _process_value(value: str) -> Tuple[str, Union[str, type]]: 229 | name, _, val = value.partition("=") 230 | name = name.replace("-", "_") 231 | return (name, val) if choices else (name, param_type(val)) 232 | 233 | final_choices = [*choices, []] if choices else None 234 | 235 | return ArgProps( 236 | type=_process_value, 237 | choices=final_choices, 238 | nargs=nargs, 239 | action=_KwargsAction, 240 | dest="kwargs", 241 | ) 242 | 243 | 244 | def _get_regular_arg_props( 245 | param_type: type, 246 | _1: List[str], 247 | _2: Optional[Union[str, int]], 248 | nargs: Optional[str], 249 | action: str, 250 | dest: str, 251 | ) -> ArgProps: 252 | """ 253 | Function for processing regular arguments(positional and *args) 254 | :param param_type: Parameter type 255 | :param _1: the list of arguments from argparse (ignored) 256 | :param _2: previous nargs (ignored) 257 | :param nargs: new nargs 258 | :param action: for add_argument() 259 | :param dest: for add_argument() 260 | :return: 261 | """ 262 | arg_type, choices = _process_type(param_type) 263 | final_choices = [*choices, []] if choices else None 264 | return ArgProps( 265 | type=arg_type, choices=final_choices, nargs=nargs, action=action, dest=dest 266 | ) 267 | 268 | 269 | _PARAM_KIND_MAP: Final[Dict[str, Callable]] = { 270 | "VAR_POSITIONAL": partial( 271 | _get_regular_arg_props, nargs="*", action="extend", dest="pos_args" 272 | ), 273 | "VAR_KEYWORD": _get_kwargs_arg_props, 274 | "POSITIONAL_OR_KEYWORD": partial( 275 | _get_regular_arg_props, nargs=None, action="append", dest="pos_args" 276 | ), 277 | } 278 | 279 | 280 | def _make_arg_help( 281 | arg_name: str, param_docs: Optional[Dict[str, str]], choices: Optional[List[str]] 282 | ): 283 | arg_help = ( 284 | param_docs.get( 285 | arg_name, 286 | "[ERROR] Missing argument docstring or the name in the docstring mismatches", 287 | ) 288 | if param_docs 289 | else "[ERROR] Docstring format seems to be incorrect or is completely missing" 290 | ) 291 | if choices: 292 | arg_help += f" {choices}" 293 | return arg_help 294 | 295 | 296 | def _make_arg_metavar(arg_name: str, arg_dest: str) -> str: 297 | if arg_dest == "kwargs": 298 | arg_name += " =" 299 | 300 | return arg_name 301 | 302 | 303 | def _add_command_arg( 304 | parser: ArgumentParser, 305 | arg_name: str, 306 | param: Parameter, 307 | param_docs: Optional[Dict[str, str]], 308 | args: List[str], 309 | nargs: Union[str, int, None], 310 | ) -> Union[str, int, None]: 311 | """ 312 | Here we are converting function arguments from the signature to CLI argument. 313 | I.E each function argument is a separate CLI argument. 314 | Example: def poll(name: str, age: int, *args, **kwargs) - 315 | that means poll command expects name and age as positional CLI arguments, 316 | if passed *args and **kwargs will be treated accordingly. 317 | :param parser: parser object from argparse 318 | :param arg_name: actual argument name from the function signature 319 | :param param: actual Parameter from the function signature 320 | :param param_docs: the dict of argument_name: help message parsed from the docstring 321 | :param args: list of command line arguments 322 | :param nargs: previous nargs for checking *args, **kwargs order 323 | :return: 324 | """ 325 | arg_props = _PARAM_KIND_MAP[param.kind.name](param.annotation, args, nargs) 326 | choices = ( 327 | arg_props["choices"][:-1] 328 | if type(arg_props["choices"]) is list 329 | else arg_props["choices"] 330 | ) 331 | arg_help = _make_arg_help(arg_name, param_docs, choices) 332 | arg_metavar = _make_arg_metavar(arg_name, arg_props["dest"]) 333 | parser.add_argument( 334 | **arg_props, 335 | help=arg_help, 336 | metavar=arg_metavar, 337 | ) 338 | return arg_props["nargs"] 339 | 340 | 341 | def _convert_docstring_to_param_docs(params: Optional[List[str]]) -> Dict[str, str]: 342 | """ 343 | Here we are converting the docstring arguments of the function to the dictionary. 344 | :param params: The list of arguments from the docstring of the function 345 | :return: 346 | """ 347 | param_docs: Dict[str, str] = {} 348 | if params is not None and [""] != params: 349 | _build_param_docs(params, param_docs) 350 | return param_docs 351 | 352 | 353 | def _build_param_docs(params: List[str], param_docs: Dict[str, str]) -> None: 354 | """ 355 | Build parameters documentation dictionary 356 | :param params: list of docstrings from function specification 357 | :param param_docs: resulting dictionary 358 | """ 359 | for arg in params: 360 | match_ = ARG_PATTERN.match(arg) 361 | if not match_: 362 | break 363 | _add_param_doc(param_docs, match_) 364 | 365 | 366 | def _add_param_doc(param_docs: Dict[str, str], match: Match[AnyStr]) -> None: 367 | """ 368 | Insert one parameter documentation into dictionary 369 | :param param_docs: resulting dictionary 370 | :param match: outcome of the regular expression matching 371 | """ 372 | param_name = str(match[1]).lstrip("*") 373 | param_doc = str(match[2]) 374 | param_docs[param_name] = param_doc 375 | 376 | 377 | def _add_version(parser: ArgumentParser, module: ModuleType) -> None: 378 | """ 379 | Implementing --version argument if there is a __version__ defined in the module(package) 380 | :param parser: parser object from the argparse 381 | :param module: actual imported module 382 | :return: 383 | """ 384 | version_ = _get_version(module) 385 | if version_: 386 | parser.add_argument( 387 | "-v", "--version", action="version", version="%(prog)s - v" + version_ 388 | ) 389 | 390 | 391 | def _get_args_from_spec(spec: Optional[str]) -> Optional[List[str]]: 392 | if not spec: 393 | return None 394 | args_spec, _, _ = spec.partition("Return:") 395 | return args_spec.strip().split("\n") 396 | 397 | 398 | def _get_python_name(iter_: Iterator) -> str: 399 | return next(iter_).replace("-", "_") 400 | 401 | 402 | def _get_cli_name(name: str) -> str: 403 | return name.replace("_", "-") 404 | 405 | 406 | def _get_all__(module: ModuleType) -> List[str]: 407 | return module.__dict__.get("__all__", []) 408 | 409 | 410 | def _get_version(module: ModuleType) -> Optional[str]: 411 | return module.__dict__.get("__version__") 412 | 413 | 414 | def _get_root_description() -> Tuple[Optional[str], ModuleType]: 415 | main_module = sys.modules["__main__"] 416 | return main_module.__doc__, main_module 417 | 418 | 419 | class _ArgParsingContext: 420 | def __init__( 421 | self, 422 | root_packages: Optional[List[str]], 423 | search_path: List[str], 424 | args: List[str], 425 | ) -> None: 426 | self._root_packages = ( 427 | [r if r.endswith(".") else f"{r}." for r in root_packages] 428 | if root_packages 429 | else [""] 430 | ) 431 | 432 | self._search_path = [p if p.endswith("/") else f"{p}/" for p in search_path] 433 | self._args = args 434 | self._root_parser: ArgumentParser = None # type: ignore 435 | self._current_subparsers: ArgumentParser._Subparsers = [] # type: ignore 436 | self._current_package: ModuleType = None # type: ignore 437 | self._current_command = None 438 | self._known_names: set[str] = set() 439 | 440 | def set_root_parser(self, arg: str) -> None: 441 | description, main_module = _get_root_description() 442 | setattr(ArgumentParser, "_check_value", _choices_patch()) 443 | self._root_parser = ArgumentParser( 444 | prog=path.basename(arg), 445 | description=description, 446 | ) 447 | self._current_subparsers = self._root_parser.add_subparsers() 448 | _add_version(self._root_parser, main_module) 449 | 450 | def _set_known_names(self): 451 | for name, module in self._current_package.__dict__.items(): 452 | if _is_public(name) and ( 453 | _is_callable(module) or _is_module_shortcut(name, self._current_package) 454 | ): 455 | self._known_names.add(name) 456 | 457 | def _add_known_functions(self): 458 | for name in self._known_names: 459 | module = self._current_package.__dict__[name] 460 | if ( 461 | (_is_callable(module) and _is_from_public_path(module.__module__)) 462 | and hasattr(module, "__code__") 463 | and _is_origin_from_search_path( 464 | module.__code__.co_filename, self._search_path 465 | ) 466 | ): 467 | self.add_command_parser(name, self._current_package) 468 | 469 | def _add_known_modules(self): 470 | for name in self._known_names: 471 | module = self._current_package.__dict__[name] 472 | if ( 473 | not _is_callable(module) 474 | and _is_module_shortcut(name, self._current_package) 475 | and ( 476 | hasattr(module, "__package__") 477 | and _is_from_public_path(module.__package__) 478 | ) 479 | and _is_origin_from_search_path(module.__file__, self._search_path) 480 | ): 481 | self.add_feature_parser(name, module) 482 | 483 | def build_all_features_help(self) -> None: 484 | """ 485 | Here we are iterating through the search path and registering all features. 486 | Effectively is equal to: -h run 487 | :return: 488 | """ 489 | 490 | self._add_parsers( 491 | [ 492 | (path_ + root_.replace(".", "/"))[:-1] 493 | for root_, path_ in product(self._root_packages, self._search_path) 494 | ] 495 | ) 496 | 497 | def build_feature_help(self) -> None: 498 | self._set_known_names() 499 | self.build_all_features_help() 500 | # You may argue about repeated for loops; the issue is that the order of function and module additions to the argparse matters; 501 | self._add_known_functions() 502 | self._add_known_modules() 503 | 504 | def build_features_help_with_all_(self, module: ModuleType) -> None: 505 | """ 506 | Same as build_all_features_help, 507 | except we are not scanning the search path but only __all__ if specified 508 | :param module: actual imported module 509 | :return: 510 | """ 511 | for name_ in _get_all__(module): 512 | if _is_public(name_): 513 | self._add_parser(name_) 514 | 515 | def execute(self) -> None: 516 | """ 517 | This is for actual execution of our CLI command - for Python this is an actual function call 518 | :return: 519 | """ 520 | args = vars(self._root_parser.parse_args()) 521 | if not args and not self._current_command: 522 | self._root_parser.print_usage() 523 | sys.exit(1) 524 | _execute_command(args, self._current_command) 525 | 526 | def import_module(self, name) -> ModuleType: 527 | err_msg = None 528 | for package in self._root_packages: 529 | full_name = package + name 530 | try: 531 | return import_module(full_name) 532 | except ImportError as err: 533 | if f"No module named '{full_name}'" != err.msg: 534 | err_msg = err.msg 535 | break 536 | except Exception as err: 537 | err_msg = str(err) 538 | break 539 | 540 | raise ImportError(f"{name} - {err_msg}") 541 | 542 | def add_feature_parser(self, name: str, module: ModuleType) -> None: 543 | self._root_packages = [f"{module.__name__}."] 544 | parser = self._current_subparsers.add_parser( 545 | _get_cli_name(name), help=_get_feature_help(module) 546 | ) 547 | self._current_subparsers = parser.add_subparsers() 548 | _add_version(parser, module) 549 | 550 | def add_feature(self, name: str, module: ModuleType) -> None: 551 | self.add_feature_parser(_get_cli_name(name), module) 552 | self._current_package = module 553 | # f_name = module.__file__ or "" 554 | # self._search_path = [p for p in self._search_path if f_name.startswith(p)] 555 | 556 | def add_command_parser(self, name: str, module: ModuleType) -> None: 557 | command = module.__dict__[name] 558 | description, spec = _parse_command_doc(command) 559 | arg_docs = _convert_docstring_to_param_docs(_get_args_from_spec(spec)) 560 | parser = self._build_command_executor( 561 | command, _get_cli_name(name), description, arg_docs 562 | ) 563 | _add_version(parser, module) 564 | return None 565 | 566 | def build_module_feature_help(self, module: ModuleType) -> None: 567 | """ 568 | Here we are registering module as feature functions as subparsers(i.e as commands); 569 | by scanning the module dictionary. 570 | :param module: imported module 571 | :return: 572 | """ 573 | for name, command in module.__dict__.items(): 574 | if _is_public(name) and _is_callable(command): 575 | self._current_subparsers.add_parser( 576 | name, help=_get_command_description(command) 577 | ) 578 | 579 | def build_module_feature_help_with_all_(self, module: ModuleType) -> None: 580 | """ 581 | Same as build_module_feature_help, except we are scanning __all__ if it is defined. 582 | :param module: import module 583 | :return: 584 | """ 585 | for name_ in _get_all__(module): 586 | self._current_subparsers.add_parser( 587 | name_, help=_get_command_description(command=module.__dict__[name_]) 588 | ) 589 | 590 | def _add_parsers(self, paths_: List[str]) -> None: 591 | for module_info in sorted(iter_modules(paths_), key=lambda m: m.name): 592 | name = module_info.name 593 | if _is_public(name) and name not in self._known_names: 594 | self._add_parser(name) 595 | 596 | def _add_parser(self, name: str) -> None: 597 | try: 598 | module = self.import_module(name) 599 | help_ = _get_module_help(name, module) 600 | except ImportError as err: 601 | help_ = f"[ERROR] failed to import {err.msg}" 602 | finally: 603 | self._current_subparsers.add_parser(_get_cli_name(name), help=help_) 604 | 605 | def _build_command_executor( 606 | self, 607 | command: Callable, 608 | name: str, 609 | description: str, 610 | param_docs: Optional[Dict[str, str]], 611 | ) -> ArgumentParser: 612 | """ 613 | Here we build complete command executor functionality - 614 | I.E registering each argument of the function; 615 | as CLI argument of the command 616 | :param command: the function object 617 | :param name: the command name to register as subparser 618 | :param description: help description of the command 619 | :param param_docs: arguments dictionary with help messages 620 | :return: parser object 621 | """ 622 | parser = self._current_subparsers.add_parser(name, description=description) 623 | sig_ = signature(command) 624 | self._current_command = command 625 | nargs = None 626 | try: 627 | for arg_name, param in sig_.parameters.items(): 628 | nargs = _add_command_arg( 629 | parser, arg_name, param, param_docs, self._args, nargs 630 | ) 631 | except ValueError as err: 632 | parser.error(str(err)) 633 | return parser 634 | 635 | 636 | # ArgParsing State Machine controlling gradual progress; 637 | # from the cli script name to command via intermediate features 638 | 639 | _ArgParsingState = Optional[ 640 | Callable[[Iterator[str], _ArgParsingContext], Optional[Type["_ArgParsingState"]]] 641 | ] 642 | 643 | 644 | def _waiting_for_feature_module_command(module: ModuleType) -> _ArgParsingState: 645 | def _check_feature_module_command( 646 | iter_: Iterator[str], context: _ArgParsingContext 647 | ) -> None: 648 | try: 649 | context.add_command_parser(_get_python_name(iter_), module) 650 | return None 651 | except (StopIteration, KeyError): 652 | context.build_module_feature_help(module) 653 | 654 | return _check_feature_module_command 655 | 656 | 657 | def _waiting_for_feature_module_all_(module: ModuleType) -> _ArgParsingState: 658 | def _check_feature_module_command_all_( 659 | iter_: Iterator[str], context: _ArgParsingContext 660 | ) -> None: 661 | try: 662 | name = _get_python_name(iter_) 663 | if name in _get_all__(module): 664 | context.add_command_parser(name, module) 665 | return None 666 | except (StopIteration, KeyError): 667 | pass 668 | context.build_module_feature_help_with_all_(module) 669 | return None 670 | 671 | return _check_feature_module_command_all_ 672 | 673 | 674 | def _waiting_for_feature_package_all_(module: ModuleType) -> _ArgParsingState: 675 | def _check_feature_all_( 676 | iter_: Iterator[str], context: _ArgParsingContext 677 | ) -> Optional[_ArgParsingState]: 678 | try: 679 | name = _get_python_name(iter_) 680 | module_ = context.import_module(name) 681 | if name in _get_all__(module): 682 | return _choose_state(context, module_, name) 683 | except (StopIteration, ImportError): 684 | pass 685 | context.build_features_help_with_all_(module) 686 | return None 687 | 688 | return _check_feature_all_ 689 | 690 | 691 | def _waiting_for_all_( 692 | name: str, module: ModuleType, context: _ArgParsingContext 693 | ) -> _ArgParsingState: 694 | if _is_package(module): 695 | context.add_feature(name, module) 696 | return _waiting_for_feature_package_all_(module) 697 | else: 698 | context.add_feature_parser(name, module) 699 | return _waiting_for_feature_module_all_(module) 700 | 701 | 702 | def _waiting_for_first_feature_or_command( 703 | iter_: Iterator[str], context: _ArgParsingContext 704 | ) -> Optional[_ArgParsingState]: 705 | try: 706 | name = _get_python_name(iter_) 707 | module = context.import_module(name) 708 | return _choose_state(context, module, name) 709 | except (StopIteration, ImportError): 710 | context.build_all_features_help() 711 | return None 712 | 713 | 714 | def _waiting_for_nested_feature_or_command( 715 | iter_: Iterator[str], context: _ArgParsingContext 716 | ) -> Optional[_ArgParsingState]: 717 | try: 718 | name = _get_python_name(iter_) 719 | curr_package = context._current_package 720 | if _is_package_command(name, curr_package): 721 | context.add_command_parser(name, curr_package) 722 | return 723 | if _is_module_shortcut(name, curr_package): 724 | module_ = curr_package.__dict__[name] 725 | context.add_feature_parser(name, module_) 726 | return _waiting_for_feature_module_command(module_) 727 | module = context.import_module(name) 728 | return _choose_state(context, module, name) 729 | except (StopIteration, ImportError): 730 | context.build_feature_help() 731 | return None 732 | 733 | 734 | def _choose_state( 735 | context: _ArgParsingContext, module: ModuleType, name: str 736 | ) -> Optional[_ArgParsingState]: 737 | try: 738 | if "__all__" in module.__dict__: 739 | return _waiting_for_all_(name, module, context) 740 | elif _is_package(module): 741 | context.add_feature(name, module) 742 | return _waiting_for_nested_feature_or_command 743 | elif _is_feature_module(name, module): 744 | context.add_feature_parser(name, module) 745 | return _waiting_for_feature_module_command(module) 746 | else: 747 | context.add_command_parser(name, module) 748 | return None 749 | except (StopIteration, ImportError, KeyError): 750 | context.build_all_features_help() 751 | return None 752 | 753 | 754 | def _initial_state( 755 | iter_: Iterator[str], context: _ArgParsingContext 756 | ) -> _ArgParsingState: 757 | context.set_root_parser(next(iter_)) 758 | return _waiting_for_first_feature_or_command 759 | 760 | 761 | def main( 762 | search_path: List[str], 763 | root_packages: Optional[List[str]] = None, 764 | ) -> None: 765 | """ 766 | This is the main entrypoint for the CLI. 767 | :param search_path: the list of paths to look for features 768 | :param root_packages: (optional) the list of root package names 769 | :return: 770 | """ 771 | args = [arg for arg in sys.argv if arg not in {"-h", "--help", "-v", "--version"}] 772 | iter_ = iter(args) 773 | context = _ArgParsingContext(root_packages, search_path, args) 774 | current_state = _initial_state 775 | while current_state is not None: 776 | current_state = current_state(iter_, context) 777 | context.execute() 778 | 779 | 780 | __all__: Final[List[Callable]] = ["main"] 781 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/test/__init__.py -------------------------------------------------------------------------------- /test/integrated/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BstLabs/py-dynacli/082e5e4425b1de92afe8b9c10b8d6401c0feedee/test/integrated/__init__.py -------------------------------------------------------------------------------- /test/integrated/storage_F/cli/dev/feature_A/__init__.py: -------------------------------------------------------------------------------- 1 | """Testing *args and **kwargs with Enum type""" 2 | -------------------------------------------------------------------------------- /test/integrated/storage_F/cli/dev/feature_A/color.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Color(Enum): 5 | RED = 1 6 | GREEN = 2 7 | BLUE = 3 8 | 9 | 10 | def color(environment: str, *colors: Color, **kwargs: str) -> None: 11 | """ 12 | Show me the colors: str, *args: Enum, **kwargs: str 13 | 14 | Args: 15 | environment (str): environment name (e.g. Cloud9 IDE stack) 16 | *colors (Enum): nice colors 17 | **kwargs (str): keyword arguments 18 | 19 | Return: None 20 | """ 21 | print(f"{environment} -> {colors} -> {kwargs}") 22 | -------------------------------------------------------------------------------- /test/integrated/storage_F/cli/dev/feature_A/colors.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Color(Enum): 5 | RED = 1 6 | GREEN = 2 7 | BLUE = 3 8 | 9 | 10 | def colors(environment: str, *colors: Color, **new_colors: Color) -> None: 11 | """ 12 | Show me the colors: str, *args: Enum, **kwargs: Enum 13 | 14 | Args: 15 | environment (str): environment name (e.g. Cloud9 IDE stack) 16 | *colors (Enum): nice colors 17 | **new_colors (Enum): color choices to accept 18 | 19 | Return: None 20 | """ 21 | print(f"{environment} -> {colors} -> {new_colors}") 22 | -------------------------------------------------------------------------------- /test/integrated/storage_F/cli/dev/feature_A/extra_colors.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Color(Enum): 5 | RED = 1 6 | GREEN = 2 7 | BLUE = 3 8 | 9 | 10 | def extra_colors(environment: str, *colors: Color) -> None: 11 | """ 12 | Show me the colors: str, *args: Enum 13 | 14 | Args: 15 | environment (str): environment name (e.g. Cloud9 IDE stack) 16 | *colors (Enum): nice colors 17 | 18 | Return: None 19 | """ 20 | print(f"{environment} -> {colors}") 21 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/admin/feature_C/__init__.py: -------------------------------------------------------------------------------- 1 | """For admin users""" 2 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/admin/feature_C/admin.py: -------------------------------------------------------------------------------- 1 | def admin(name: str) -> None: 2 | """ 3 | get the name of the admin user 4 | 5 | Args: 6 | name (str): name of the admin user 7 | 8 | Return: None 9 | """ 10 | print(name) 11 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/admin/feature_C/user.py: -------------------------------------------------------------------------------- 1 | def user(first_name: str, last_name: str, **kwargs: str) -> None: 2 | """ 3 | get the name and last name and other information of the user 4 | 5 | Args: 6 | first_name (str): name of the user 7 | last_name (str): last name of the user 8 | *kwargs (str): keyword arguments 9 | 10 | Return: None 11 | """ 12 | print(first_name, last_name, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/destroy.py: -------------------------------------------------------------------------------- 1 | def destroy(name: str) -> None: 2 | """ 3 | Destroy given name... 4 | 5 | Args: 6 | 7 | name (str): Name of project 8 | 9 | Return: None 10 | """ 11 | print(f"This is a top level destroyer - {name}") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Does something useful 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/create.py: -------------------------------------------------------------------------------- 1 | def create(environment: str, project: str, **kwargs: str) -> None: 2 | """ 3 | create the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | **kwargs (str): the keyword length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/destroy.py: -------------------------------------------------------------------------------- 1 | def destroy(environment: str, project: str, *x: int, **y: int) -> None: 2 | """ 3 | destroy the project and its libraries 4 | 5 | Args: 6 | project (str): name of the service 7 | environment (str): environment name (e.g. Cloud9 IDE stack) 8 | **y (str): the keyword length list of libraries 9 | *x (str): the variable length list of libraries 10 | 11 | Return: None 12 | """ 13 | print(environment, project, x, y) 14 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/init.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | def init(name: str, path: Optional[str] = None): 5 | """ 6 | init the project in given path 7 | 8 | Args: 9 | name (str): name of the project 10 | path (str): name of the path 11 | 12 | Return: None 13 | """ 14 | print(f"Initializing the {name} in {path}") 15 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/new.py: -------------------------------------------------------------------------------- 1 | def new(name: str) -> None: 2 | """ 3 | Creates a new service skeleton 4 | 5 | Args: 6 | 7 | name (str): Name of project 8 | 9 | Return: None 10 | """ 11 | print(f"This is a new {name}!") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/package.py: -------------------------------------------------------------------------------- 1 | def package(environment: str, project: str, *args: str) -> None: 2 | """ 3 | package the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | *args (str): the variable length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, args) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/feature_A/shutdown.py: -------------------------------------------------------------------------------- 1 | def shutdown(environment: str, service: str) -> None: 2 | """ 3 | delete the service cloud formation stack 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | service (str): name of the service 8 | 9 | Return: None 10 | """ 11 | print(f"This is a shutdown of {service} from {environment}!") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of module feature 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service 9 | 10 | Args: 11 | 12 | name (str): Name of project 13 | 14 | Return: None 15 | """ 16 | print(f"This is a module as feature {name}") 17 | 18 | 19 | def shutdown(name: str, environment: str) -> None: 20 | """ 21 | Shutdown a service 22 | 23 | Args: 24 | 25 | name (str): Name of project 26 | environment (str): Name of the env 27 | 28 | Return: None 29 | """ 30 | print(f"Shutdown a module as feature {name} {environment}") 31 | 32 | 33 | def _init(): 34 | """ 35 | This should not be shown 36 | :return: 37 | """ 38 | ... 39 | 40 | 41 | def __revert(): 42 | """ 43 | This should not be shown 44 | :return: 45 | """ 46 | ... 47 | -------------------------------------------------------------------------------- /test/integrated/storage_X/cli/dev/update.py: -------------------------------------------------------------------------------- 1 | __version__ = "5.5" 2 | 3 | 4 | def update() -> None: 5 | """ 6 | Updates everything... 7 | 8 | Args: 9 | Return: None 10 | """ 11 | print("This is a top level command...") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/admin/feature_D/__init__.py: -------------------------------------------------------------------------------- 1 | """Do not forget about this feature for admins""" 2 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/admin/feature_D/admin.py: -------------------------------------------------------------------------------- 1 | def admin(name: str) -> None: 2 | """ 3 | get the name of the admin user 4 | 5 | Args: 6 | name (str): name of the admin user 7 | 8 | Return: None 9 | """ 10 | print(name) 11 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/fake.py: -------------------------------------------------------------------------------- 1 | def change(name: str) -> None: 2 | # Command with mismatched argument name in docstring and function definition 3 | """ 4 | change the name 5 | 6 | Args: 7 | kkk (str): wrong argument name 8 | 9 | 10 | Return: None 11 | """ 12 | print(f"Change {name=}") 13 | 14 | 15 | def remove(name: str) -> None: 16 | # Command with missing in docstring 17 | """ 18 | remove the name 19 | 20 | Args: 21 | 22 | Return: None 23 | """ 24 | print(f"Remove {name=}") 25 | 26 | 27 | def drop(name: str) -> None: 28 | # Command with missing return in docstring 29 | """ 30 | drop the name 31 | 32 | Args: 33 | name (str): We love to drop 34 | """ 35 | print(f"Drop {name=}") 36 | 37 | 38 | def detect(name: str) -> None: 39 | # Command with only description docstring 40 | """ 41 | detect the name 42 | """ 43 | print(f"Detect {name=}") 44 | 45 | 46 | def lonely() -> None: 47 | # Command with no docstring 48 | print("I am alone here") 49 | 50 | 51 | def love(name: str) -> None: 52 | # Command with no docstring 53 | print(f"Love {name=}") 54 | 55 | 56 | def unsupported(name: str) -> None: 57 | # Command with docstring with wrong type in it 58 | """ 59 | unsupported name 60 | 61 | Args: 62 | name str: should fail 63 | 64 | Return: None 65 | """ 66 | print(name) 67 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Does something extremely useful 3 | """ 4 | 5 | from . import * 6 | 7 | __all__ = [ 8 | "destroy", 9 | "color", 10 | "shape", 11 | "unknown_world", 12 | "distance", 13 | "terminate", 14 | "make", 15 | ] 16 | __version__ = "1.0" 17 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/color.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Color(Enum): 5 | RED = 1 6 | GREEN = 2 7 | BLUE = 3 8 | 9 | 10 | def color(environment: str, colors: Color) -> None: 11 | """ 12 | Show me the colors 13 | 14 | Args: 15 | environment (str): environment name (e.g. Cloud9 IDE stack) 16 | colors (Enum): color choices to accept 17 | 18 | Return: None 19 | """ 20 | print(f"{environment} -> {colors}") 21 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/create.py: -------------------------------------------------------------------------------- 1 | def create(environment: str, project: str, **kwargs: str) -> None: 2 | """ 3 | create the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | **kwargs (str): the keyword length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/destroy.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2" 2 | 3 | 4 | def destroy(environment: str, project: str, *args: str, **kwargs: str) -> None: 5 | """ 6 | destroy the project and its libraries 7 | 8 | Args: 9 | project (str): name of the service 10 | environment (str): environment name (e.g. Cloud9 IDE stack) 11 | **kwargs (str): the keyword length list of libraries 12 | *args (str): the variable length list of libraries 13 | 14 | Return: None 15 | """ 16 | print(environment, project, args, kwargs) 17 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/distance.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List 3 | 4 | 5 | class Distance(Enum): 6 | LONG = 1 7 | SHORT = 2 8 | 9 | 10 | def distance(highway: str, turns: List[int], *args: str, **kwargs: int) -> None: 11 | """ 12 | Show me the distances 13 | 14 | Args: 15 | highway (str): The name of the highway 16 | turns (list): the list of the turns on the way 17 | **kwargs (str): the keyword length list of libraries 18 | *args (str): the variable length list of libraries 19 | 20 | Return: None 21 | """ 22 | print(highway, turns, args, kwargs) 23 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/make.py: -------------------------------------------------------------------------------- 1 | def make(env: str, is_new: bool) -> None: 2 | """ 3 | make the env if it is a new one 4 | 5 | Args: 6 | env (str): name of the env 7 | is_new (str): if it is a new env or not 8 | 9 | Return: None 10 | """ 11 | print(f"Initializing the {env} if it is a {is_new}") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/shape.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Shape(Enum): 5 | RECTANGLE = 1 6 | TRIANGLE = 2 7 | CIRCLE = 3 8 | 9 | 10 | def shape(environment: str, shapes: Shape, *args: str, **kwargs: str) -> None: 11 | """ 12 | Show me the shapes 13 | 14 | Args: 15 | environment (str): environment name (e.g. Cloud9 IDE stack) 16 | shapes (Enum): shape choices to accept 17 | 18 | Return: None 19 | """ 20 | print(environment, shapes, args, kwargs) 21 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/terminate.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | __version__ = "3.3" 4 | 5 | 6 | def terminate( 7 | environment: str, project: str, fraction: float, extra_fraction: Optional[float] 8 | ) -> None: 9 | """ 10 | Terminator 11 | 12 | Args: 13 | project (str): name of the service 14 | environment (str): environment name (e.g. Cloud9 IDE stack) 15 | fraction (float): the fraction 16 | extra_fraction (float): extra fraction 17 | 18 | Return: None 19 | """ 20 | print(environment, project, fraction, extra_fraction) 21 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/feature_B/unknown_world.py: -------------------------------------------------------------------------------- 1 | def unknown_world(name: str, address: str) -> None: 2 | """ 3 | This is something unknown 4 | Args: 5 | name (str): Name of the unknown thing 6 | address (str): Address of the unknown thing 7 | 8 | Return: None 9 | """ 10 | print(f"Unknowns {name} {address}") 11 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/the_last.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of module feature 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service 9 | 10 | Args: 11 | 12 | name (str): Name of project 13 | 14 | Return: None 15 | """ 16 | print(f"This is a module as feature {name}") 17 | 18 | 19 | def shutdown(name: str, environment: str) -> None: 20 | """ 21 | Shutdown a service 22 | 23 | Args: 24 | 25 | name (str): Name of project 26 | environment (str): Name of the env 27 | 28 | Return: None 29 | """ 30 | print(f"Shutdown a module as feature {name} {environment}") 31 | 32 | 33 | def _init(): 34 | """ 35 | This should not be shown 36 | :return: 37 | """ 38 | ... 39 | 40 | 41 | def __revert(): 42 | """ 43 | This should not be shown 44 | :return: 45 | """ 46 | ... 47 | -------------------------------------------------------------------------------- /test/integrated/storage_Y/cli/dev/upload.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of module feature 3 | """ 4 | __all__ = ["shutdown"] 5 | __version__ = "2.0" 6 | 7 | 8 | def new(name: str) -> None: 9 | """ 10 | Creates a new service 11 | 12 | Args: 13 | 14 | name (str): Name of project 15 | 16 | Return: None 17 | """ 18 | print(f"This is a module as feature {name}") 19 | 20 | 21 | def shutdown(name: str, environment: str) -> None: 22 | """ 23 | Shutdown a service 24 | 25 | Args: 26 | 27 | name (str): Name of project 28 | environment (str): Name of the env 29 | 30 | Return: None 31 | """ 32 | print(f"Shutdown a module as feature {name} {environment}") 33 | 34 | 35 | def _init(): 36 | """ 37 | This should not be shown 38 | :return: 39 | """ 40 | ... 41 | 42 | 43 | def __revert(): 44 | """ 45 | This should not be shown 46 | :return: 47 | """ 48 | ... 49 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/admin/feature_Z/__init__.py: -------------------------------------------------------------------------------- 1 | """For admin users""" 2 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/admin/feature_Z/admin.py: -------------------------------------------------------------------------------- 1 | def admin(name: str) -> None: 2 | """ 3 | get the name of the admin user 4 | 5 | Args: 6 | name (str): name of the admin user 7 | 8 | Return: None 9 | """ 10 | print(name) 11 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/admin/feature_Z/start.py: -------------------------------------------------------------------------------- 1 | def start(name: str, **kwargs: str) -> None: 2 | """ 3 | Start something new 4 | 5 | Args: 6 | name (str): name of the start 7 | **kwargs (str): pass some keyword arguments 8 | 9 | Return: None 10 | """ 11 | 12 | print(name, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/admin/feature_Z/user.py: -------------------------------------------------------------------------------- 1 | def user(first_name: str, last_name: str, **kwargs: str) -> None: 2 | """ 3 | get the name and last name and other information of the user 4 | 5 | Args: 6 | first_name (str): name of the user 7 | last_name (str): last name of the user 8 | *kwargs (str): keyword arguments 9 | 10 | Return: None 11 | """ 12 | print(first_name, last_name, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/_common/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common things should go here 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/_common/session.py: -------------------------------------------------------------------------------- 1 | def get_session(name: str) -> str: 2 | """ 3 | Start the session 4 | 5 | Args: 6 | name (str): the session name 7 | 8 | Return: None 9 | """ 10 | return name 11 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Top level package 3 | """ 4 | 5 | __all__ = ["feature_B", "feature_C"] 6 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Nested package example 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/create.py: -------------------------------------------------------------------------------- 1 | def create(environment: str, project: str, **kwargs: str) -> None: 2 | """ 3 | create the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | **kwargs (str): the keyword length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/destroy.py: -------------------------------------------------------------------------------- 1 | def destroy(environment: str, project: str, *x: int, **y: int) -> None: 2 | """ 3 | destroy the project and its libraries 4 | 5 | Args: 6 | project (str): name of the service 7 | environment (str): environment name (e.g. Cloud9 IDE stack) 8 | **y (str): the keyword length list of libraries 9 | *x (str): the variable length list of libraries 10 | 11 | Return: None 12 | """ 13 | print(environment, project, x, y) 14 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/init.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | def init(name: str, path: Optional[str] = None): 5 | """ 6 | init the project in given path 7 | 8 | Args: 9 | name (str): name of the project 10 | path (str): name of the path 11 | 12 | Return: None 13 | """ 14 | print(f"Initializing the {name} in {path}") 15 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/new.py: -------------------------------------------------------------------------------- 1 | def new(name: str) -> None: 2 | """ 3 | Creates a new service skeleton 4 | 5 | Args: 6 | 7 | name (str): Name of project 8 | 9 | Return: None 10 | """ 11 | print(f"This is a new {name}!") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/package.py: -------------------------------------------------------------------------------- 1 | def package(environment: str, project: str, *args: str) -> None: 2 | """ 3 | package the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | *args (str): the variable length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, args) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_B/shutdown.py: -------------------------------------------------------------------------------- 1 | def shutdown(environment: str, service: str) -> None: 2 | """ 3 | delete the service cloud formation stack 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | service (str): name of the service 8 | 9 | Return: None 10 | """ 11 | print(f"This is a shutdown of {service} from {environment}!") 12 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_C/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wonderful feature C 3 | """ 4 | 5 | __all__ = ["create"] 6 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_C/create.py: -------------------------------------------------------------------------------- 1 | def create(environment: str, project: str, **kwargs: str) -> None: 2 | """ 3 | create the project and its libraries 4 | 5 | Args: 6 | environment (str): environment name (e.g. Cloud9 IDE stack) 7 | project (str): name of the service 8 | **kwargs (str): the keyword length list of libraries 9 | 10 | Return: None 11 | """ 12 | print(environment, project, kwargs) 13 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_C/destroy.py: -------------------------------------------------------------------------------- 1 | # This should not be shown in the -h output 2 | 3 | 4 | def destroy(environment: str, project: str, *x: int, **y: int) -> None: 5 | """ 6 | destroy the project and its libraries 7 | 8 | Args: 9 | project (str): name of the service 10 | environment (str): environment name (e.g. Cloud9 IDE stack) 11 | **y (str): the keyword length list of libraries 12 | *x (str): the variable length list of libraries 13 | 14 | Return: None 15 | """ 16 | print(environment, project, x, y) 17 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_A/feature_D/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Should not be displayed 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_B/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Here we are going to use function shortcut directly importing it in __init__.py 3 | """ 4 | from os import environ, path 5 | 6 | from .feature_F.service import new 7 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_B/feature_F/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Package where the function as command stored 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_B/feature_F/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of function as command 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service - example of directly importing function in __init__.py 9 | 10 | Args: 11 | 12 | name (str): Name of project (function as command) 13 | 14 | Return: None 15 | """ 16 | print(f"This is a callable as a command {name}") 17 | 18 | 19 | def shutdown(name: str, environment: str) -> None: 20 | """ 21 | Shutdown a service 22 | 23 | Args: 24 | 25 | name (str): Name of project 26 | environment (str): Name of the env 27 | 28 | Return: None 29 | """ 30 | print(f"Shutdown a module as feature {name} {environment}") 31 | 32 | 33 | def _init(): 34 | """ 35 | This should not be shown 36 | :return: 37 | """ 38 | ... 39 | 40 | 41 | def __revert(): 42 | """ 43 | This should not be shown 44 | :return: 45 | """ 46 | ... 47 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_B/new.py: -------------------------------------------------------------------------------- 1 | """ 2 | Awesome new module - WILL be ignored in favor of __init__.py 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service - this is from new.py 9 | 10 | Args: 11 | 12 | name (str): Name of project (feature command) 13 | 14 | Return: None 15 | """ 16 | print(f"This is a callable as a command {name}") 17 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_C/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Awesome 3 | """ 4 | 5 | # This will be ignored as well as it does not come from the search_path/root_package 6 | from os.path import join 7 | from typing import Type 8 | 9 | # This imported function should be ignored as it is not from the public path 10 | from .._common.session import get_session 11 | from ..feature_A.feature_B.create import create 12 | from .feature_F import service 13 | from .feature_F.service import new 14 | 15 | 16 | # So you can use any kind of common functionality here. 17 | # We consider the imported functionality as nested feature commands if it came from public path. 18 | # Otherwise, it will be ignored and will not be exposed in CLI. 19 | def _use_session(): 20 | get_session("fake session") 21 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_C/feature_F/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The awesome feature-F 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_C/feature_F/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of module - which is imported in __init__.py 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service - example of directly importing module in __init__.py 9 | 10 | Args: 11 | 12 | name (str): Name of project (function as command) 13 | 14 | Return: None 15 | """ 16 | print(f"This is a callable as a command {name}") 17 | 18 | 19 | def shutdown(name: str, environment: str) -> None: 20 | """ 21 | Shutdown a service - example of directly importing module in __init__.py 22 | 23 | Args: 24 | 25 | name (str): Name of project 26 | environment (str): Name of the env 27 | 28 | Return: None 29 | """ 30 | print(f"Shutdown a module as feature {name} {environment}") 31 | 32 | 33 | def _init(): 34 | """ 35 | This should not be shown 36 | :return: 37 | """ 38 | ... 39 | 40 | 41 | def __revert(): 42 | """ 43 | This should not be shown 44 | :return: 45 | """ 46 | ... 47 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_C/new.py: -------------------------------------------------------------------------------- 1 | """ 2 | Awesome new module - WILL be ignored in favor of __init__.py 3 | """ 4 | 5 | 6 | def new(name: str) -> None: 7 | """ 8 | Creates a new service - this is from new.py 9 | 10 | Args: 11 | 12 | name (str): Name of project (feature command) 13 | 14 | Return: None 15 | """ 16 | print(f"This is a callable as a command {name}") 17 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_C/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | Should be ignored 3 | """ 4 | 5 | 6 | def shutdown(name: str, environment: str) -> None: 7 | """ 8 | Shutdown a service - should be ignored 9 | 10 | Args: 11 | 12 | name (str): Name of project 13 | environment (str): Name of the env 14 | 15 | Return: None 16 | """ 17 | print(f"Shutdown a module as feature {name} {environment}") 18 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_D/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Imitating the import errors at module level 3 | """ 4 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_D/new.py: -------------------------------------------------------------------------------- 1 | import xxxx 2 | 3 | 4 | def new(name: str) -> None: 5 | """ 6 | Creates a new service 7 | 8 | Args: 9 | 10 | name (str): Name of project 11 | 12 | Return: None 13 | """ 14 | print(f"This is a module as feature {name}") 15 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/feature_F/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Imitating the package import errors 3 | """ 4 | 5 | import xxxx 6 | -------------------------------------------------------------------------------- /test/integrated/storage_Z/cli/dev/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an example of module feature 3 | """ 4 | 5 | __all__ = ["new"] 6 | 7 | 8 | def new(name: str) -> None: 9 | """ 10 | Creates a new service 11 | 12 | Args: 13 | 14 | name (str): Name of project 15 | 16 | Return: None 17 | """ 18 | print(f"This is a module as feature {name}") 19 | 20 | 21 | def shutdown(name: str, environment: str) -> None: 22 | """ 23 | Shutdown a service 24 | 25 | Args: 26 | 27 | name (str): Name of project 28 | environment (str): Name of the env 29 | 30 | Return: None 31 | """ 32 | print(f"Shutdown a module as feature {name} {environment}") 33 | 34 | 35 | def _init(): 36 | """ 37 | This should not be shown 38 | :return: 39 | """ 40 | ... 41 | 42 | 43 | def __revert(): 44 | """ 45 | This should not be shown 46 | :return: 47 | """ 48 | ... 49 | -------------------------------------------------------------------------------- /test/integrated/suite/testcli: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] 2 | {destroy,fake,feature-A,feature-B,service,the-last,update,upload} 3 | ... 4 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli --version: -------------------------------------------------------------------------------- 1 | testcli - v22.2 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli -h: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] 2 | {destroy,fake,feature-A,feature-B,service,the-last,update,upload} 3 | ... 4 | 5 | Sample DynaCLI Tool 6 | 7 | positional arguments: 8 | {destroy,fake,feature-A,feature-B,service,the-last,update,upload} 9 | destroy Destroy given name... 10 | fake [ERROR] Missing the module docstring 11 | feature-A Does something useful 12 | feature-B Does something extremely useful 13 | service This is an example of module feature 14 | the-last This is an example of module feature 15 | update Updates everything... 16 | upload This is an example of module feature 17 | 18 | optional arguments: 19 | -h, --help show this help message and exit 20 | -v, --version show program's version number and exit 21 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli -v: -------------------------------------------------------------------------------- 1 | testcli - v22.2 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli destroy awesome: -------------------------------------------------------------------------------- 1 | This is a top level destroyer - awesome 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli fake -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake [-h] 2 | {change,remove,drop,detect,lonely,love,unsupported} ... 3 | 4 | positional arguments: 5 | {change,remove,drop,detect,lonely,love,unsupported} 6 | change change the name 7 | remove remove the name 8 | drop drop the name 9 | detect detect the name 10 | lonely [ERROR] Missing command docstring 11 | love [ERROR] Missing command docstring 12 | unsupported unsupported name 13 | 14 | optional arguments: 15 | -h, --help show this help message and exit 16 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] {feature-A} ... 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-A [-h] {create,destroy,init,new,package,shutdown} ... 2 | 3 | positional arguments: 4 | {create,destroy,init,new,package,shutdown} 5 | create create the project and its libraries 6 | destroy destroy the project and its libraries 7 | init init the project in given path 8 | new Creates a new service skeleton 9 | package package the project and its libraries 10 | shutdown delete the service cloud formation stack 11 | 12 | optional arguments: 13 | -h, --help show this help message and exit 14 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A create cloudenv mypackage name1=lib1 name2=lib2 name3=lib3 name4=lib4: -------------------------------------------------------------------------------- 1 | cloudenv mypackage {'name1': 'lib1', 'name2': 'lib2', 'name3': 'lib3', 'name4': 'lib4'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A destroy cloudenv mypackage: -------------------------------------------------------------------------------- 1 | cloudenv mypackage () {} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A destroy cloudenv mypackage 1 2 3 4: -------------------------------------------------------------------------------- 1 | cloudenv mypackage (1, 2, 3, 4) {} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A destroy cloudenv mypackage 1 2 3 4 name1=1 name2=2 name3=3 name4=4: -------------------------------------------------------------------------------- 1 | cloudenv mypackage (1, 2, 3, 4) {'name1': 1, 'name2': 2, 'name3': 3, 'name4': 4} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A destroy cloudenv mypackage name1=1 name2=2 name3=3 name4=4: -------------------------------------------------------------------------------- 1 | cloudenv mypackage () {'name1': 1, 'name2': 2, 'name3': 3, 'name4': 4} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A init myproject --path fff: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-A init [-h] name 3 | testcli feature-A init: error: Unsupported argument type typing.Optional[str] 4 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A new: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-A new [-h] name 3 | testcli feature-A new: error: the following arguments are required: name 4 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A new world: -------------------------------------------------------------------------------- 1 | This is a new world! 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A package cloudenv mypackage lib1 lib2 lib3 lib4: -------------------------------------------------------------------------------- 1 | cloudenv mypackage ('lib1', 'lib2', 'lib3', 'lib4') 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A shutdown cloudenv servicename: -------------------------------------------------------------------------------- 1 | This is a shutdown of servicename from cloudenv! 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-A shutdown cloudenv servicename xxxx: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli [-h] [-v] {feature-A} ... 3 | testcli: error: unrecognized arguments: xxxx 4 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] {feature-B} ... 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B --version: -------------------------------------------------------------------------------- 1 | testcli feature-B - v1.0 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-B [-h] [-v] 2 | {destroy,color,shape,unknown-world,distance,terminate,make} 3 | ... 4 | 5 | positional arguments: 6 | {destroy,color,shape,unknown-world,distance,terminate,make} 7 | destroy destroy the project and its libraries 8 | color Show me the colors 9 | shape Show me the shapes 10 | unknown-world This is something unknown 11 | distance Show me the distances 12 | terminate Terminator 13 | make make the env if it is a new one 14 | 15 | optional arguments: 16 | -h, --help show this help message and exit 17 | -v, --version show program's version number and exit 18 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B -v: -------------------------------------------------------------------------------- 1 | testcli feature-B - v1.0 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B color myenv RED: -------------------------------------------------------------------------------- 1 | myenv -> RED 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B destroy --version: -------------------------------------------------------------------------------- 1 | testcli feature-B destroy - v1.2 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B destroy -v: -------------------------------------------------------------------------------- 1 | testcli feature-B destroy - v1.2 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B distance -h: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-B distance [-h] highway 3 | testcli feature-B distance: error: Unsupported argument type typing.List[int] 4 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B make myenv True: -------------------------------------------------------------------------------- 1 | Initializing the myenv if it is a True 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B shape: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-B shape [-h] 3 | environment shapes [args ...] 4 | [kwargs = ...] 5 | testcli feature-B shape: error: the following arguments are required: environment, shapes, args, kwargs = 6 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B shape myenv CIRCLE: -------------------------------------------------------------------------------- 1 | myenv CIRCLE () {} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B shape myenv CIRCLE lib1 lib2 name1=lib1 name2=lib2: -------------------------------------------------------------------------------- 1 | myenv CIRCLE ('lib1', 'lib2') {'name1': 'lib1', 'name2': 'lib2'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B shape myenv XXXX lib1 name1=lib1: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-B shape [-h] 3 | environment shapes [args ...] kwargs 4 | = 5 | testcli feature-B shape: error: argument shapes: invalid choice: 'XXXX' (choose from 'RECTANGLE', 'TRIANGLE', 'CIRCLE') 6 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli feature-B unknown-world na-me add-ress: -------------------------------------------------------------------------------- 1 | Unknowns na-me add-ress 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli service: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] {service} ... 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli service -h: -------------------------------------------------------------------------------- 1 | usage: testcli service [-h] {new,shutdown} ... 2 | 3 | positional arguments: 4 | {new,shutdown} 5 | new Creates a new service 6 | shutdown Shutdown a service 7 | 8 | optional arguments: 9 | -h, --help show this help message and exit 10 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli service new xxxx: -------------------------------------------------------------------------------- 1 | This is a module as feature xxxx 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli the-last -h: -------------------------------------------------------------------------------- 1 | usage: testcli the-last [-h] {new,shutdown} ... 2 | 3 | positional arguments: 4 | {new,shutdown} 5 | new Creates a new service 6 | shutdown Shutdown a service 7 | 8 | optional arguments: 9 | -h, --help show this help message and exit 10 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli update: -------------------------------------------------------------------------------- 1 | This is a top level command... 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli update --version: -------------------------------------------------------------------------------- 1 | testcli update - v5.5 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli update -v: -------------------------------------------------------------------------------- 1 | testcli update - v5.5 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli upload: -------------------------------------------------------------------------------- 1 | usage: testcli [-h] [-v] {upload} ... 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli upload --version: -------------------------------------------------------------------------------- 1 | testcli upload - v2.0 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli upload -h: -------------------------------------------------------------------------------- 1 | usage: testcli upload [-h] [-v] {shutdown} ... 2 | 3 | positional arguments: 4 | {shutdown} 5 | shutdown Shutdown a service 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | -v, --version show program's version number and exit 10 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcli upload -v: -------------------------------------------------------------------------------- 1 | testcli upload - v2.0 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcliadmin -h: -------------------------------------------------------------------------------- 1 | usage: testcliadmin [-h] [-v] 2 | {destroy,fake,feature-A,feature-B,feature-C,feature-D,service,the-last,update,upload} 3 | ... 4 | 5 | Sample DynaCLI Tool 6 | 7 | positional arguments: 8 | {destroy,fake,feature-A,feature-B,feature-C,feature-D,service,the-last,update,upload} 9 | destroy Destroy given name... 10 | fake [ERROR] Missing the module docstring 11 | feature-A Does something useful 12 | feature-B Does something extremely useful 13 | feature-C For admin users 14 | feature-D Do not forget about this feature for admins 15 | service This is an example of module feature 16 | the-last This is an example of module feature 17 | update Updates everything... 18 | upload This is an example of module feature 19 | 20 | optional arguments: 21 | -h, --help show this help message and exit 22 | -v, --version show program's version number and exit 23 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcliadmin feature-C -h: -------------------------------------------------------------------------------- 1 | usage: testcliadmin feature-C [-h] {admin,user} ... 2 | 3 | positional arguments: 4 | {admin,user} 5 | admin get the name of the admin user 6 | user get the name and last name and other information of the user 7 | 8 | optional arguments: 9 | -h, --help show this help message and exit 10 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testcliadmin feature-C user shako rzayev address=Baku age=32: -------------------------------------------------------------------------------- 1 | shako rzayev {'address': 'Baku', 'age': '32'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested -h: -------------------------------------------------------------------------------- 1 | usage: testclinested [-h] [-v] 2 | {feature-A,feature-B,feature-C,feature-D,feature-F,feature-Z,service} 3 | ... 4 | 5 | Sample DynaCLI Tool for nested package tests 6 | 7 | positional arguments: 8 | {feature-A,feature-B,feature-C,feature-D,feature-F,feature-Z,service} 9 | feature-A Top level package 10 | feature-B Here we are going to use function shortcut directly 11 | importing it in __init__.py 12 | feature-C Awesome 13 | feature-D Imitating the import errors at module level 14 | feature-F [ERROR] failed to import feature_F - No module named 15 | 'xxxx' 16 | feature-Z For admin users 17 | service This is an example of module feature 18 | 19 | optional arguments: 20 | -h, --help show this help message and exit 21 | -v, --version show program's version number and exit 22 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-A -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-A [-h] {feature-B,feature-C} ... 2 | 3 | positional arguments: 4 | {feature-B,feature-C} 5 | feature-B Nested package example 6 | feature-C Wonderful feature C 7 | 8 | optional arguments: 9 | -h, --help show this help message and exit 10 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-A feature-B -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-A feature-B [-h] 2 | {create,destroy,init,new,package,shutdown} 3 | ... 4 | 5 | positional arguments: 6 | {create,destroy,init,new,package,shutdown} 7 | create create the project and its libraries 8 | destroy destroy the project and its libraries 9 | init init the project in given path 10 | new Creates a new service skeleton 11 | package package the project and its libraries 12 | shutdown delete the service cloud formation stack 13 | 14 | optional arguments: 15 | -h, --help show this help message and exit 16 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-A feature-C -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-A feature-C [-h] {create} ... 2 | 3 | positional arguments: 4 | {create} 5 | create create the project and its libraries 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-A feature-C create asdas asdasd: -------------------------------------------------------------------------------- 1 | asdas asdasd {} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-B feature-F service -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-B feature-F service [-h] {new,shutdown} ... 2 | 3 | positional arguments: 4 | {new,shutdown} 5 | new Creates a new service - example of directly importing 6 | function in __init__.py 7 | shutdown Shutdown a service 8 | 9 | optional arguments: 10 | -h, --help show this help message and exit 11 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-B feature-F service new myservice: -------------------------------------------------------------------------------- 1 | This is a callable as a command myservice 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-B new myservice: -------------------------------------------------------------------------------- 1 | This is a callable as a command myservice 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-C -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-C [-h] {feature-F,create,new,service} ... 2 | 3 | positional arguments: 4 | {feature-F,create,new,service} 5 | feature-F The awesome feature-F 6 | create create the project and its libraries 7 | new Creates a new service - example of directly importing 8 | module in __init__.py 9 | service This is an example of module - which is imported in 10 | __init__.py 11 | 12 | optional arguments: 13 | -h, --help show this help message and exit 14 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-C service -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-C service [-h] {new,shutdown} ... 2 | 3 | positional arguments: 4 | {new,shutdown} 5 | new Creates a new service - example of directly importing module 6 | in __init__.py 7 | shutdown Shutdown a service - example of directly importing module in 8 | __init__.py 9 | 10 | optional arguments: 11 | -h, --help show this help message and exit 12 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-D -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-D [-h] {new} ... 2 | 3 | positional arguments: 4 | {new} 5 | new [ERROR] failed to import new - No module named 'xxxx' 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-F -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-F [-h] 2 | 3 | optional arguments: 4 | -h, --help show this help message and exit 5 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclinested feature-Z start name extra-arg=wohoo: -------------------------------------------------------------------------------- 1 | name {'extra_arg': 'wohoo'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testclisimple -h: -------------------------------------------------------------------------------- 1 | usage: testclisimple [-h] [-v] {destroy,feature-A,service,update} ... 2 | 3 | Sample DynaCLI Tool without root package 4 | 5 | positional arguments: 6 | {destroy,feature-A,service,update} 7 | destroy Destroy given name... 8 | feature-A Does something useful 9 | service This is an example of module feature 10 | update Updates everything... 11 | 12 | optional arguments: 13 | -h, --help show this help message and exit 14 | -v, --version show program's version number and exit 15 | --- 16 | -------------------------------------------------------------------------------- /test/integrated/suite/testclisimple feature-A -h: -------------------------------------------------------------------------------- 1 | usage: testclisimple feature-A [-h] 2 | {create,destroy,init,new,package,shutdown} ... 3 | 4 | positional arguments: 5 | {create,destroy,init,new,package,shutdown} 6 | create create the project and its libraries 7 | destroy destroy the project and its libraries 8 | init init the project in given path 9 | new Creates a new service skeleton 10 | package package the project and its libraries 11 | shutdown delete the service cloud formation stack 12 | 13 | optional arguments: 14 | -h, --help show this help message and exit 15 | --- 16 | -------------------------------------------------------------------------------- /test/integrated/suite/testenum -h: -------------------------------------------------------------------------------- 1 | usage: testenum [-h] [-v] {feature-A} ... 2 | 3 | Sample DynaCLI Tool 4 | 5 | positional arguments: 6 | {feature-A} 7 | feature-A Testing *args and **kwargs with Enum type 8 | 9 | optional arguments: 10 | -h, --help show this help message and exit 11 | -v, --version show program's version number and exit 12 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testenum feature-A -h: -------------------------------------------------------------------------------- 1 | usage: testenum feature-A [-h] {color,colors,extra-colors} ... 2 | 3 | positional arguments: 4 | {color,colors,extra-colors} 5 | color Show me the colors: str, *args: Enum, **kwargs: str 6 | colors Show me the colors: str, *args: Enum, **kwargs: Enum 7 | extra-colors Show me the colors: str, *args: Enum 8 | 9 | optional arguments: 10 | -h, --help show this help message and exit 11 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testenum feature-A color myenv: -------------------------------------------------------------------------------- 1 | myenv -> () -> {} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testenum feature-A color myenv RED name=value: -------------------------------------------------------------------------------- 1 | myenv -> ('RED',) -> {'name': 'value'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testenum feature-A colors myenv RED GREEN color=BLUE: -------------------------------------------------------------------------------- 1 | myenv -> ('RED', 'GREEN') -> {'color': 'BLUE'} 2 | --- -------------------------------------------------------------------------------- /test/integrated/suite/testenum feature-A colors myenv RED GREEN color=nope: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testenum feature-A colors [-h] 3 | environment [colors ...] new_colors 4 | = 5 | testenum feature-A colors: error: argument new_colors =: invalid choice: 'nope' (choose from 'RED', 'GREEN', 'BLUE') 6 | --- -------------------------------------------------------------------------------- /test/integrated/test_dynacli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from os import environ, path 5 | from subprocess import run 6 | from typing import Tuple, Union 7 | from unittest import TestCase 8 | 9 | PY38 = sys.version_info >= (3, 8) 10 | PY39 = sys.version_info >= (3, 9) 11 | 12 | skip_ = { 13 | PY38: [ 14 | "Unsupported argument type typing.Optional[str]", 15 | "[kwargs = ...]", 16 | "[colors ...]", 17 | "[x ...]", 18 | "[args ...]", 19 | "usage: testclinested feature-C [-h] {feature-F,create,new,service}", 20 | "usage: testclinested feature-C [-h] {feature-F,new,create,service}", 21 | ], 22 | PY39: [ 23 | "usage: testclinested feature-C [-h] {feature-F,create,new,service}", 24 | "usage: testclinested feature-C [-h] {feature-F,new,create,service}", 25 | ], 26 | } 27 | 28 | 29 | def _get_expected(file_path: str) -> Union[Tuple[str, None], Tuple[str, str]]: 30 | with open(file_path, "r") as f: 31 | outputs = f.read().split("---") 32 | if len(outputs) < 2: 33 | return outputs[0], "" 34 | return outputs[0], outputs[1].lstrip() 35 | 36 | 37 | class TestDynaCLI(TestCase): 38 | """Runs suite tests of DynaCLI""" 39 | 40 | def setUp(self): 41 | cwd = path.dirname(__file__) 42 | environ["PATH"] = f'{environ["PATH"]}:{cwd}' 43 | 44 | def test_cli(self) -> None: 45 | dirname_ = os.path.dirname(__file__) 46 | self.maxDiff = None 47 | skips = skip_[PY38] if PY38 else skip_[PY39] 48 | for test in os.listdir(f"{dirname_}/suite"): 49 | file_name, _ = os.path.splitext(test) 50 | cmd = file_name.split(" ") 51 | file_path = f"{dirname_}/suite/{test}" 52 | stdout, stderr = _get_expected(file_path) 53 | with self.subTest(cmd=file_name): 54 | print("Running ", cmd) 55 | if [skip for skip in skips if skip in stderr or skip in stdout]: 56 | continue 57 | result = run(cmd, capture_output=True, env=environ, text=True) 58 | self.assertEqual(stderr, result.stderr) 59 | self.assertEqual(stdout, result.stdout) 60 | 61 | 62 | if __name__ == "__main__": 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /test/integrated/testcli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Sample DynaCLI Tool 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | cwd = os.path.dirname(os.path.realpath(__file__)) 11 | base, _, _ = cwd.partition('/test') 12 | sys.path.append(f'{base}/src/dynacli') 13 | __version__ = "22.2" 14 | 15 | from dynacli import main 16 | 17 | search_path = [f'{cwd}/storage_X/', f'{cwd}/storage_Y/'] 18 | root_packages = ['cli.dev'] 19 | sys.path.extend(search_path) 20 | 21 | main(search_path, root_packages) 22 | -------------------------------------------------------------------------------- /test/integrated/testcliadmin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Sample DynaCLI Tool 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | cwd = os.path.dirname(os.path.realpath(__file__)) 11 | base, _, _ = cwd.partition('/test') 12 | sys.path.append(f'{base}/src/dynacli') 13 | __version__ = "22.2" 14 | 15 | from dynacli import main 16 | 17 | # Here we define search path - where the packages are stored in the file system 18 | search_path = [f'{cwd}/storage_X/', f'{cwd}/storage_Y/'] 19 | # Adding root packages to be imported as root for features; please keep in mind that this is not a common case; 20 | root_packages = ['cli.dev', 'cli.admin'] 21 | sys.path.extend(search_path) 22 | 23 | main(search_path, root_packages) 24 | -------------------------------------------------------------------------------- /test/integrated/testclinested: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Sample DynaCLI Tool for nested package tests 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | cwd = os.path.dirname(os.path.realpath(__file__)) 11 | base, _, _ = cwd.partition("/test") 12 | sys.path.append(f"{base}/src/dynacli") 13 | __version__ = "02.1" 14 | 15 | from dynacli import main 16 | 17 | search_path = [f"{cwd}/storage_Z/"] 18 | 19 | root_packages = ["cli.dev", "cli.admin"] 20 | 21 | sys.path.extend(search_path) 22 | 23 | main( 24 | search_path, 25 | root_packages, 26 | ) 27 | -------------------------------------------------------------------------------- /test/integrated/testclisimple: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Sample DynaCLI Tool without root package 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | cwd = os.path.dirname(os.path.realpath(__file__)) 11 | base, _, _ = cwd.partition('/test') 12 | sys.path.append(f'{base}/src/dynacli') 13 | __version__ = "02.1" 14 | 15 | from dynacli import main 16 | 17 | search_path = [f'{cwd}/storage_X/cli/dev'] 18 | sys.path.extend(search_path) 19 | 20 | main(search_path) 21 | -------------------------------------------------------------------------------- /test/integrated/testenum: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Sample DynaCLI Tool 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | cwd = os.path.dirname(os.path.realpath(__file__)) 11 | base, _, _ = cwd.partition("/test") 12 | sys.path.append(f"{base}/src/dynacli") 13 | __version__ = "22.2" 14 | 15 | from dynacli import main 16 | 17 | search_path = [f"{cwd}/storage_F/"] 18 | root_packages = ["cli.dev"] 19 | sys.path.extend(search_path) 20 | 21 | main(search_path, root_packages) 22 | -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake change -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake change [-h] name 2 | 3 | positional arguments: 4 | name [ERROR] Missing argument docstring or the name in the docstring 5 | mismatches 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake detect -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake detect [-h] name 2 | 3 | positional arguments: 4 | name [ERROR] Docstring format seems to be incorrect or is completely 5 | missing 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake drop -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake drop [-h] name 2 | 3 | positional arguments: 4 | name We love to drop 5 | 6 | optional arguments: 7 | -h, --help show this help message and exit 8 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake lonely -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake lonely [-h] 2 | 3 | optional arguments: 4 | -h, --help show this help message and exit 5 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake love -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake love [-h] name 2 | 3 | positional arguments: 4 | name [ERROR] Docstring format seems to be incorrect or is completely 5 | missing 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake remove -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake remove [-h] name 2 | 3 | positional arguments: 4 | name [ERROR] Docstring format seems to be incorrect or is completely 5 | missing 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli fake unsupported -h: -------------------------------------------------------------------------------- 1 | usage: testcli fake unsupported [-h] name 2 | 3 | positional arguments: 4 | name [ERROR] Docstring format seems to be incorrect or is completely 5 | missing 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-A destroy -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-A destroy [-h] 2 | environment project [x ...] 3 | [y = ...] 4 | 5 | positional arguments: 6 | environment environment name (e.g. Cloud9 IDE stack) 7 | project name of the service 8 | x the variable length list of libraries 9 | y = the keyword length list of libraries 10 | 11 | optional arguments: 12 | -h, --help show this help message and exit 13 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-A new -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-A new [-h] name 2 | 3 | positional arguments: 4 | name Name of project 5 | 6 | optional arguments: 7 | -h, --help show this help message and exit 8 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-A new world -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-A new [-h] name 2 | 3 | positional arguments: 4 | name Name of project 5 | 6 | optional arguments: 7 | -h, --help show this help message and exit 8 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-A yyyy: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-A [-h] {create,destroy,init,new,package,shutdown} ... 3 | testcli feature-A: error: argument {create,destroy,init,new,package,shutdown}: invalid choice: 'yyyy' (choose from 'create', 'destroy', 'init', 'new', 'package', 'shutdown') 4 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-B color -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-B color [-h] environment colors 2 | 3 | positional arguments: 4 | environment environment name (e.g. Cloud9 IDE stack) 5 | colors color choices to accept ['RED', 'GREEN', 'BLUE'] 6 | 7 | optional arguments: 8 | -h, --help show this help message and exit 9 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-B create -h: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli feature-B [-h] [-v] 3 | {destroy,color,shape,unknown-world,distance,terminate,make} 4 | ... 5 | testcli feature-B: error: argument {destroy,color,shape,unknown-world,distance,terminate,make}: invalid choice: 'create' (choose from 'destroy', 'color', 'shape', 'unknown-world', 'distance', 'terminate', 'make') 6 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-B shape -h: -------------------------------------------------------------------------------- 1 | usage: testcli feature-B shape [-h] 2 | environment shapes [args ...] 3 | [kwargs = ...] 4 | 5 | positional arguments: 6 | environment environment name (e.g. Cloud9 IDE stack) 7 | shapes shape choices to accept ['RECTANGLE', 'TRIANGLE', 8 | 'CIRCLE'] 9 | args [ERROR] Missing argument docstring or the name in the 10 | docstring mismatches 11 | kwargs = 12 | [ERROR] Missing argument docstring or the name in the 13 | docstring mismatches 14 | 15 | optional arguments: 16 | -h, --help show this help message and exit 17 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli feature-C -h: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli [-h] [-v] 3 | {destroy,fake,feature-A,feature-B,service,the-last,update,upload} 4 | ... 5 | testcli: error: argument {destroy,fake,feature-A,feature-B,service,the-last,update,upload}: invalid choice: 'feature-C' (choose from 'destroy', 'fake', 'feature-A', 'feature-B', 'service', 'the-last', 'update', 'upload') 6 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli update -h: -------------------------------------------------------------------------------- 1 | usage: testcli update [-h] [-v] 2 | 3 | optional arguments: 4 | -h, --help show this help message and exit 5 | -v, --version show program's version number and exit 6 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli upload new -h: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli upload [-h] [-v] {shutdown} ... 3 | testcli upload: error: argument {shutdown}: invalid choice: 'new' (choose from 'shutdown') 4 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testcli xxxx: -------------------------------------------------------------------------------- 1 | --- 2 | usage: testcli [-h] [-v] 3 | {destroy,fake,feature-A,feature-B,service,the-last,update,upload} 4 | ... 5 | testcli: error: argument {destroy,fake,feature-A,feature-B,service,the-last,update,upload}: invalid choice: 'xxxx' (choose from 'destroy', 'fake', 'feature-A', 'feature-B', 'service', 'the-last', 'update', 'upload') 6 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testclinested feature-A feature-B create -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-A feature-B create [-h] 2 | environment project 3 | [kwargs = ...] 4 | 5 | positional arguments: 6 | environment environment name (e.g. Cloud9 IDE stack) 7 | project name of the service 8 | kwargs = 9 | the keyword length list of libraries 10 | 11 | optional arguments: 12 | -h, --help show this help message and exit 13 | --- -------------------------------------------------------------------------------- /test/integrated/tmp_ignored_tests/testclinested feature-B -h: -------------------------------------------------------------------------------- 1 | usage: testclinested feature-B [-h] {feature-F,new} ... 2 | 3 | positional arguments: 4 | {feature-F,new} 5 | feature-F Package where the function as command stored 6 | new Creates a new service - example of directly importing 7 | function in __init__.py 8 | 9 | optional arguments: 10 | -h, --help show this help message and exit 11 | --- -------------------------------------------------------------------------------- /tutorials/greetings/hello.py: -------------------------------------------------------------------------------- 1 | def hello(*names: str) -> None: 2 | """ 3 | Print Hello message 4 | 5 | Args: 6 | names (str): variable list of names to be included in greeting 7 | 8 | Return: None 9 | """ 10 | print(f"Hello, {' '.join(names)}") 11 | -------------------------------------------------------------------------------- /tutorials/greetings/say: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Greetings CLI 4 | """ 5 | 6 | import sys 7 | import os 8 | from typing import Final 9 | 10 | from dynacli import main 11 | 12 | cwd = os.path.dirname(os.path.realpath(__file__)) 13 | 14 | __version__: Final[str] = "1.0" 15 | 16 | search_path = [cwd] 17 | sys.path.extend(search_path) 18 | 19 | main(search_path) --------------------------------------------------------------------------------