├── .editorconfig ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── README.md ├── _archive ├── guide │ ├── 03_plugins.md │ ├── 10_debug.md │ └── examples │ │ ├── empty.py │ │ └── simple.py └── project │ ├── alternatives │ ├── index.md │ ├── manage.md │ └── paver.md │ └── decisions │ ├── choose-existing-di-library.md │ ├── di.md │ └── new-di-library.md ├── docs ├── .pages ├── _hidden.md ├── advanced │ ├── .pages │ ├── examples │ │ ├── package │ │ │ ├── __init__.py │ │ │ ├── lint.py │ │ │ └── test.py │ │ └── sub-commands.py │ ├── jeeves-package.md │ └── sub-commands.md ├── assets │ ├── cover-original.png │ ├── cover.png │ └── termynal │ │ ├── termynal.css │ │ └── termynal.js ├── examples │ ├── hello-rich.py │ ├── hello-typer.py │ ├── hello.py │ ├── hidden.py │ ├── homepage.py │ └── shell.py ├── index.md ├── jeeves-py.md ├── plugins │ ├── .pages │ ├── how-to.md │ └── why.md ├── project │ ├── decisions │ │ ├── .pages │ │ ├── shell-combinators.md │ │ ├── start-jeeves-project.md │ │ └── unpack.md │ └── roadmap.md ├── reference │ ├── environment-variables │ │ ├── JEEVES_ROOT.md │ │ └── index.md │ └── options.md ├── rich.md ├── sh.md └── typer.md ├── jeeves.py ├── jeeves_shell ├── __init__.py ├── cli.py ├── discover.py ├── entry_points.py ├── errors.py ├── import_by_path.py └── jeeves.py ├── macros.py ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── test_samples ├── empty.py ├── import_error.py ├── multiple.py ├── package │ ├── __init__.py │ └── linter.py ├── single.py ├── sub_app.py └── syntax_error.txt └── tests ├── base.py ├── conftest.py ├── test_cli.py ├── test_construct_root_app.py ├── test_jeeves_file.py └── test_plugins.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py, pyi}] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [Makefile] 18 | indent_style = tab 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # https://blog.elmah.io/deploying-a-mkdocs-documentation-site-with-github-actions/ 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | jobs: 7 | build: 8 | name: Deploy to GitHub pages 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.11 15 | - run: pip install -q poetry 16 | - run: poetry config virtualenvs.create false --local 17 | - run: poetry install 18 | - run: pip install jeeves-yeti-pyproject>=0.2.40 19 | - uses: actions/checkout@v3 20 | with: 21 | repository: jeeves-sh/mkdocs-material-insiders 22 | path: mkdocs-material-insiders 23 | token: ${{ secrets.INSIDERS_TOKEN }} 24 | 25 | - run: j install-graphviz 26 | - run: j install-mkdocs-insiders 27 | - run: j deploy-to-github-pages 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.11', '3.12'] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install poetry 27 | run: | 28 | pip install 'poetry<1.5' 29 | 30 | # Adding `poetry` to `$PATH`: 31 | echo "$HOME/.poetry/bin" >> $GITHUB_PATH 32 | 33 | - name: Set up cache 34 | uses: actions/cache@v2 35 | with: 36 | path: .venv 37 | key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} 38 | - name: Install dependencies 39 | run: | 40 | poetry config virtualenvs.in-project true 41 | poetry run pip install -U pip jeeves-yeti-pyproject 42 | poetry install 43 | 44 | - name: Lint 45 | run: poetry run j lint 46 | 47 | - name: Test 48 | run: poetry run j test 49 | 50 | # Upload coverage to codecov: https://codecov.io/ 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v1 53 | with: 54 | file: ./coverage.xml 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### joe made this: http://goel.io/joe 2 | #### macos #### 3 | # General 4 | *.DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | #### linux #### 31 | *~ 32 | 33 | # temporary files which can be created if a process still has a handle open of a deleted file 34 | .fuse_hidden* 35 | 36 | # KDE directory preferences 37 | .directory 38 | 39 | # Linux trash folder which might appear on any partition or disk 40 | .Trash-* 41 | 42 | # .nfs files are created when an open file is removed but is still being accessed 43 | .nfs* 44 | #### windows #### 45 | # Windows thumbnail cache files 46 | Thumbs.db 47 | ehthumbs.db 48 | ehthumbs_vista.db 49 | 50 | # Dump file 51 | *.stackdump 52 | 53 | # Folder config file 54 | Desktop.ini 55 | 56 | # Recycle Bin used on file shares 57 | $RECYCLE.BIN/ 58 | 59 | # Windows Installer files 60 | *.cab 61 | *.msi 62 | *.msm 63 | *.msp 64 | 65 | # Windows shortcuts 66 | *.lnk 67 | #### python #### 68 | # Byte-compiled / optimized / DLL files 69 | __pycache__/ 70 | *.py[cod] 71 | *$py.class 72 | 73 | # C extensions 74 | *.so 75 | 76 | # Distribution / packaging 77 | .Python 78 | build/ 79 | develop-eggs/ 80 | dist/ 81 | downloads/ 82 | eggs/ 83 | .eggs/ 84 | lib/ 85 | lib64/ 86 | parts/ 87 | sdist/ 88 | var/ 89 | wheels/ 90 | *.egg-info/ 91 | .installed.cfg 92 | *.egg 93 | 94 | # PyInstaller 95 | # Usually these files are written by a python script from a template 96 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 97 | *.manifest 98 | *.spec 99 | 100 | # Installer logs 101 | pip-log.txt 102 | pip-delete-this-directory.txt 103 | 104 | # Unit test / coverage reports 105 | htmlcov/ 106 | .tox/ 107 | .coverage 108 | .coverage.* 109 | .cache 110 | nosetests.xml 111 | coverage.xml 112 | *.cover 113 | .hypothesis/ 114 | 115 | # Translations 116 | *.mo 117 | *.pot 118 | 119 | # Django stuff: 120 | *.log 121 | local_settings.py 122 | 123 | # Flask stuff: 124 | instance/ 125 | .webassets-cache 126 | 127 | # Scrapy stuff: 128 | .scrapy 129 | 130 | # Sphinx documentation 131 | docs/_build/ 132 | 133 | # PyBuilder 134 | target/ 135 | 136 | # Jupyter Notebook 137 | .ipynb_checkpoints 138 | 139 | # celery beat schedule file 140 | celerybeat-schedule 141 | 142 | # SageMath parsed files 143 | *.sage.py 144 | 145 | # Environments 146 | .env 147 | .venv 148 | env/ 149 | venv/ 150 | ENV/ 151 | 152 | # Spyder project settings 153 | .spyderproject 154 | .spyproject 155 | 156 | # Rope project settings 157 | .ropeproject 158 | 159 | # mkdocs documentation 160 | /site 161 | 162 | # mypy 163 | .mypy_cache/ 164 | 165 | #### jetbrains #### 166 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 167 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 168 | 169 | # User-specific stuff: 170 | .idea/**/workspace.xml 171 | .idea/**/tasks.xml 172 | .idea/dictionaries 173 | 174 | # Sensitive or high-churn files: 175 | .idea/**/dataSources/ 176 | .idea/**/dataSources.ids 177 | .idea/**/dataSources.xml 178 | .idea/**/dataSources.local.xml 179 | .idea/**/sqlDataSources.xml 180 | .idea/**/dynamic.xml 181 | .idea/**/uiDesigner.xml 182 | 183 | # Gradle: 184 | .idea/**/gradle.xml 185 | .idea/**/libraries 186 | 187 | # CMake 188 | cmake-build-debug/ 189 | 190 | # Mongo Explorer plugin: 191 | .idea/**/mongoSettings.xml 192 | 193 | ## File-based project format: 194 | *.iws 195 | 196 | ## Plugin-specific files: 197 | 198 | # IntelliJ 199 | /out/ 200 | 201 | # mpeltonen/sbt-idea plugin 202 | .idea_modules/ 203 | 204 | # JIRA plugin 205 | atlassian-ide-plugin.xml 206 | 207 | # Cursive Clojure plugin 208 | .idea/replstate.xml 209 | 210 | # Crashlytics plugin (for Android Studio and IntelliJ) 211 | com_crashlytics_export_strings.xml 212 | crashlytics.properties 213 | crashlytics-build.properties 214 | fabric.properties 215 | 216 | .idea/ 217 | .flakeheaven_cache/ 218 | 219 | .python-version 220 | mkdocs-material-insiders/ 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jeeves Shell 2 | 3 | [![Build Status](https://github.com/jeeves-sh/jeeves-shell/workflows/test/badge.svg?branch=master&event=push)](https://github.com/jeeves-sh/jeeves-shell/actions?query=workflow%3Atest) 4 | [![codecov](https://codecov.io/gh/jeeves-sh/jeeves-shell/branch/master/graph/badge.svg)](https://codecov.io/gh/jeeves-sh/jeeves-shell) 5 | [![Python Version](https://img.shields.io/pypi/pyversions/jeeves-shell.svg)](https://pypi.org/project/jeeves-shell/) 6 | [![wemake-python-styleguide](https://img.shields.io/badge/style-wemake-000000.svg)](https://github.com/wemake-services/wemake-python-styleguide) 7 | 8 | ![](docs/assets/cover.png) 9 | 10 | A Pythonic replacement for GNU Make, with re-usability and modularity added as a bonus. 11 | 12 | Jeeves transforms your shell experience by enabling you to create custom Python-based shell commands to manage and automate your development workflows. 13 | 14 | ## Features 15 | 16 | - **Custom Shell Commands**: Construct commands to build, compile, lint, format, test, deploy, and propel your projects forward. 17 | - **Python-Powered**: Use Python for readable and maintainable workflows. 18 | - **Rich Integrations**: Stylish command output with `rich` and `sh`. 19 | - **Plugin System**: Share your setup across projects. 20 | 21 | ## Quick Start 22 | 23 | Install with pip: 24 | 25 | pip install 'jeeves-shell[all]' 26 | 27 | Or with poetry: 28 | 29 | poetry add --group dev --extras=all jeeves-shell 30 | 31 | ## Example 32 | 33 | Create a file named `jeeves.py` in the root of your project. 34 | 35 | ```python 36 | import rich 37 | import sh 38 | 39 | 40 | def hi(): 41 | """Hello world.""" 42 | user_name = sh.whoami() 43 | machine = sh.uname('-a') 44 | 45 | rich.print(f'Hello [b]{user_name}[/b]!') 46 | rich.print(f'This code is running on: [b]{machine}[/b].') 47 | ``` 48 | 49 | And then execute in your shell: 50 | 51 | ```shell 52 | j hi 53 | ``` 54 | 55 | this should print something along the lines of: 56 | 57 | ``` 58 | Hello john-connor! 59 | This code is running on: Cyberdyne T800! 60 | ``` 61 | 62 | ## Learn More 63 | 64 | Read [the tutorial](https://jeeves.sh/jeeves-py/)! 65 | 66 | ## Credits 67 | 68 | This project was generated with [`wemake-python-package`](https://github.com/wemake-services/wemake-python-package). 69 | -------------------------------------------------------------------------------- /_archive/guide/03_plugins.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Plugins 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /_archive/guide/10_debug.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Debugging 3 | --- 4 | 5 | ## Enable debug mode 6 | 7 | ```shell 8 | j --log-level info whatever commands --with args etc 9 | ``` 10 | 11 | * This will set log level to `info`. The even more verbose option is `debug`; 12 | * Any of these levels will also mean that any unhandled exception will be printed in full, traceback included. 13 | 14 | ## Disable plugins 15 | 16 | ```shell 17 | JEEVES_DISABLE_PLUGINS=1 j 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /_archive/guide/examples/empty.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeeves-sh/jeeves-shell/c715b4c5eef0118f917f8ecf5c8555036e06efc9/_archive/guide/examples/empty.py -------------------------------------------------------------------------------- /_archive/guide/examples/simple.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from typer import Option 4 | 5 | 6 | class GreetingStyle(Enum): 7 | """Style of a greeting.""" 8 | 9 | BRITISH = 'british' 10 | COCKNEY = 'cockney' 11 | PUNK = 'punk' 12 | 13 | 14 | def hello( 15 | name: str, 16 | style: GreetingStyle = Option(GreetingStyle.BRITISH), 17 | ): 18 | """Greet the user.""" 19 | print({ 20 | GreetingStyle.BRITISH: f'How do you do {name}!', 21 | GreetingStyle.COCKNEY: f'Oi {name}!', 22 | GreetingStyle.PUNK: f'Hoi {name}!', 23 | }[style]) 24 | -------------------------------------------------------------------------------- /_archive/project/alternatives/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Alternatives 3 | --- 4 | 5 | # Alternatives 6 | -------------------------------------------------------------------------------- /_archive/project/alternatives/manage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: https://github.com/python-manage/manage 3 | --- 4 | 5 | -------------------------------------------------------------------------------- /_archive/project/alternatives/paver.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Evaluate Paver 3 | number: 4 4 | status: rejected 5 | --- 6 | 7 | ## Context 8 | 9 | [Paver](https://pythonhosted.org/Paver/) is a project with goals very similar to mine: it proposes to replace GNU Make with Python functions. I have written a little example in it. 10 | 11 | ```python 12 | from paver.tasks import task, cmdopts 13 | 14 | 15 | @task 16 | @cmdopts([ 17 | ('key=', 'k', 'Key of the task') 18 | ]) 19 | def select(options): 20 | """Select a Jira task to manipulate.""" 21 | print(options) 22 | ``` 23 | 24 | ## Decision 25 | 26 | Unfortunately, I do not see a way to use Paver. 27 | 28 | 1. It apparently does not support command line arguments. 29 | 30 | ```shell 31 | $ paver select RM3-588 32 | ---> pavement.select 33 | Namespace(dry_run=None, pavement_file='pavement.py', select=Bunch()) 34 | Build failed: Unknown task: RM3-588 35 | ``` 36 | 37 | 2. When you manage to propagate the command line option into the function, you cannot access it as a function argument. 38 | 39 | ```shell 40 | $ paver select -k RM3-588 41 | ---> pavement.select 42 | Namespace(dry_run=None, pavement_file='pavement.py', select=Bunch(key='RM3-588')) 43 | ``` 44 | 45 | So I have to dive into the `Namespace` object and then to `select` member inside it... Seriously? 46 | 47 | 3. Type annotations are not used to describe options or dependencies. I would think that a principle similar to FastAPI would be very helpful here. In particular: 48 | - By default, every argument is considered a command line argument; 49 | - Using a special `Depends()` object you can declare it as a dependency. 50 | 51 | And that's it. No additional decorators needed. 52 | 53 | ## Consequences 54 | 55 | `paver` is great but it must be reworked too heavily, I am afraid, to be useful. 56 | -------------------------------------------------------------------------------- /_archive/project/decisions/choose-existing-di-library.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Choose a DI library 3 | status: rejected 4 | 5 | criteria: 6 | requires-classes: 7 | title: Requires classes 8 | comment: The DI library cannot work on plain functions, it needs classes. 9 | weight: -1 10 | blocker: yes 11 | injectable-functions: 12 | title: Functions are injectable 13 | comment: Functions can be injected, not just classes. 14 | blocker: yes 15 | depends-annotation: 16 | title: "`Depends` annotation" 17 | comment: Annotation to automatically inject one function into another. 18 | requires-container: 19 | title: Requires a container object to be explicitly created. 20 | weight: -1 21 | blocker: yes 22 | 23 | alternatives: 24 | - id: https://github.com/proofit404/dependencies 25 | requires-classes: yes 26 | - id: https://github.com/ivankorobkov/python-inject 27 | requires-classes: no 28 | injectable-functions: no 29 | - id: https://github.com/Neoteroi/rodi 30 | requires-classes: yes 31 | - id: https://github.com/ets-labs/python-dependency-injector 32 | requires-classes: yes 33 | - id: https://github.com/bobthemighty/punq 34 | requires-classes: no 35 | depends-annotation: no 36 | - id: https://github.com/BradLewis/simple-injection 37 | requires-classes: yes 38 | - id: https://github.com/adriangb/di 39 | requires-container: yes 40 | - id: https://github.com/avito-tech/trainspotting 41 | requires-container: yes 42 | - id: https://github.com/akshay2000/Pychkari 43 | requires-container: yes 44 | --- 45 | 46 | I haven't found a suitable library. 47 | 48 | !!! warning "todo" 49 | This document is going to be expanded with a visualization of available libraries. 50 | -------------------------------------------------------------------------------- /_archive/project/decisions/di.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Jeeves needs DI 3 | status: accepted 4 | --- 5 | 6 | # Decision 7 | 8 | `jeeves` needs a Dependency Injection framework to keep its capabilities on par with GNU Make. 9 | 10 | ## Context 11 | 12 | At the moment of writing this, it is user's responsibility to ensure that: 13 | 14 | * a command calls the functions it depends upon, 15 | * functions are only called if they really need to, 16 | * and they are only called once if that is what the logic demands. 17 | 18 | -------------------------------------------------------------------------------- /_archive/project/decisions/new-di-library.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: New DI library for Jeeves 3 | status: draft 4 | --- 5 | 6 | ## Decision 7 | 8 | Implementation of such a library does not currently seem possible because of technical difficulties with the syntax we originally wanted. 9 | 10 | ## Context 11 | 12 | In order to make the syntax as concise and as similar to `GNU Make` as possible, the idea is to use `Depends()` syntax from FastAPI. 13 | 14 | ```python 15 | from pathlib import Path 16 | from dino import Depends 17 | import sh 18 | 19 | 20 | def python_packages() -> list[Path]: 21 | return [child for child in Path.cwd().itderdir() if (child / '__init__.py').is_file()] 22 | 23 | 24 | def lint(packages: list[Path] = Depends(python_packages)): 25 | sh.flakeheaven.lint(*packages) 26 | ``` 27 | 28 | ### How to implement this? 29 | 30 | `Depends` must transparently output the `list[Path]` value which the consuming code expects to see. 31 | 32 | * `Depends.__init__()` won't work: it must output an instance of `Depends`; 33 | * `Depends.__new__()` isn't going to be helpful either: it will be evaluated on module level which essentially means 34 | * we're modifying a global variable, 35 | * which is in addition a function's default parameter; 36 | * 🤔 `Depends.__get__` might be a way, but… 37 | 38 | ### Where to store the resolved value? 39 | 40 | * Inside the `Depends` instance: we again get a global mutable state; that's wrong: we should get another invocation of `python_packages` every time `lint` is called; 41 | * In silently global scope like `contextvars`: the problem is the same; 42 | * In `Typer` app context: would be curious, but the hell how to gain access there if not to write `ctx: Context` in **every** function? 43 | * This means the implementation is tied to `Typer`… but who cares. 44 | -------------------------------------------------------------------------------- /docs/.pages: -------------------------------------------------------------------------------- 1 | nav: 2 | - index.md 3 | - jeeves-py.md 4 | - _hidden.md 5 | - typer.md 6 | - sh.md 7 | - rich.md 8 | - advanced 9 | - plugins 10 | - reference 11 | -------------------------------------------------------------------------------- /docs/_hidden.md: -------------------------------------------------------------------------------- 1 | --- 2 | $id: hidden-functions 3 | title: def _hidden() 4 | hidden: examples/hidden.py 5 | hide: 6 | - toc 7 | --- 8 | 9 | # :material-eye-off: `def _hidden()` functions 10 | 11 | If function name starts from an underscore, it will _not_ be converted to a command. 12 | 13 | That is useful for helper reusable functions. 14 | 15 | {{ code(page.meta.hidden, language='python', title='jeeves.py') }} 16 | 17 | ⇒ 18 | 19 | {{ j(page.meta.hidden, environment={'JEEVES_DISABLE_PLUGINS': 'true'}) }} 20 | -------------------------------------------------------------------------------- /docs/advanced/.pages: -------------------------------------------------------------------------------- 1 | nav: 2 | - sub-commands.md 3 | - ... 4 | -------------------------------------------------------------------------------- /docs/advanced/examples/package/__init__.py: -------------------------------------------------------------------------------- 1 | from jeeves.lint import lint 2 | from jeeves.test import test 3 | -------------------------------------------------------------------------------- /docs/advanced/examples/package/lint.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | lint = typer.Typer(no_args_is_help=True) 4 | 5 | 6 | @lint.command() 7 | def mypy(): 8 | """Run mypy.""" 9 | 10 | 11 | @lint.command() 12 | def flake8(): 13 | """Run flake8.""" 14 | print(flake8.__doc__) 15 | -------------------------------------------------------------------------------- /docs/advanced/examples/package/test.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | test = typer.Typer() 4 | 5 | 6 | @test.command() 7 | def unit(): 8 | """Run unit tests.""" 9 | 10 | 11 | @test.command() 12 | def integration(): 13 | """Run integration tests.""" 14 | -------------------------------------------------------------------------------- /docs/advanced/examples/sub-commands.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | lint = typer.Typer(no_args_is_help=True) 4 | test = typer.Typer(no_args_is_help=True) 5 | 6 | 7 | @lint.command(name='mypy') 8 | def _mypy(): 9 | """Run mypy.""" 10 | 11 | 12 | @lint.command(name='flake8') 13 | def _flake8(): 14 | """Run flake8.""" 15 | print(_flake8.__doc__) 16 | 17 | 18 | @test.command(name='unit') 19 | def _unit(): 20 | """Run unit tests.""" 21 | 22 | 23 | @test.command(name='integration') 24 | def _integration(): 25 | """Run integration tests.""" 26 | -------------------------------------------------------------------------------- /docs/advanced/jeeves-package.md: -------------------------------------------------------------------------------- 1 | --- 2 | $id: jeeves-package 3 | title: Jeeves package 4 | hide: 5 | - toc 6 | 7 | package: advanced/examples/package 8 | 9 | init: advanced/examples/package/__init__.py 10 | test: advanced/examples/package/test.py 11 | lint: advanced/examples/package/lint.py 12 | --- 13 | 14 | # :package: `jeeves` package instead of :simple-python: `jeeves.py` file 15 | 16 | Instead of {{ render('jeeves.py') }} file, a Python package named `jeeves` can also be used. 17 | 18 | * That helps to structure code better if you have multiple commands, 19 | * You no longer need to make sub-commands hidden. 20 | 21 | === "jeeves/__init__.py" 22 | 23 | {{ code(page.meta.init, language='python', indent=4, title='jeeves/__init__.py') }} 24 | 25 | === "jeeves/test.py" 26 | 27 | {{ code(page.meta.test, language='python', indent=4, title="jeeves/test.py") }} 28 | 29 | === "jeeves/lint.py" 30 | 31 | {{ code(page.meta.lint, language='python', indent=4, title="jeeves/lint.py") }} 32 | 33 | ## Top level documentation 34 | 35 | {{ j(page.meta.package, environment={'JEEVES_DISABLE_PLUGINS': 'true'}) }} 36 | 37 | ## Command documentation 38 | 39 | {{ j(page.meta.package, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['lint']) }} 40 | 41 | ## Sub command documentation 42 | 43 | {{ j(page.meta.package, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['lint', 'flake8', '--help']) }} 44 | 45 | ## …and execution 46 | 47 | {{ j(page.meta.package, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['lint', 'flake8']) }} 48 | -------------------------------------------------------------------------------- /docs/advanced/sub-commands.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sub-commands 3 | subcommands: advanced/examples/sub-commands.py 4 | hide: 5 | - toc 6 | --- 7 | 8 | # :material-submarine: Sub-commands 9 | 10 | If `j` exports too many commands, it might make sense to group them into subcommands. This is done by defining [Typer](../typer/) applications in [jeeves.py](../jeeves-py). 11 | 12 | {{ code(page.meta.subcommands, language='python', title='jeeves.py') }} 13 | 14 | ## Top level documentation 15 | 16 | {{ j(page.meta.subcommands, environment={'JEEVES_DISABLE_PLUGINS': 'true'}) }} 17 | 18 | !!! info "Hidden commands" 19 | Note that we have to use underscore here, otherwise `def mypy` and other functions will be bound to the top-level `j` command. See {{ render("hidden-functions") }} for details. See {{ render("jeeves-package") }} to see how to avoid that. 20 | 21 | {# todo: Reference to an mkdocs page with render() does not generate a link. #} 22 | 23 | ## Sub-command level documentation 24 | 25 | {{ j(page.meta.subcommands, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['lint']) }} 26 | 27 | ## Nested command 28 | 29 | {{ j(page.meta.subcommands, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['lint', 'flake8']) }} 30 | -------------------------------------------------------------------------------- /docs/assets/cover-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeeves-sh/jeeves-shell/c715b4c5eef0118f917f8ecf5c8555036e06efc9/docs/assets/cover-original.png -------------------------------------------------------------------------------- /docs/assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeeves-sh/jeeves-shell/c715b4c5eef0118f917f8ecf5c8555036e06efc9/docs/assets/cover.png -------------------------------------------------------------------------------- /docs/assets/termynal/termynal.css: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * 4 | * @author Ines Montani 5 | * @version 0.0.1 6 | * @license MIT 7 | */ 8 | 9 | :root { 10 | --color-bg: #252a33; 11 | --color-text: #eee; 12 | --color-text-subtle: #a2a2a2; 13 | } 14 | 15 | [data-termynal] { 16 | width: 750px; 17 | max-width: 100%; 18 | background: var(--color-bg); 19 | color: var(--color-text); 20 | font-size: 18px; 21 | font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; 22 | border-radius: 4px; 23 | padding: 75px 45px 35px; 24 | position: relative; 25 | -webkit-box-sizing: border-box; 26 | box-sizing: border-box; 27 | } 28 | 29 | [data-termynal]:before { 30 | content: ''; 31 | position: absolute; 32 | top: 15px; 33 | left: 15px; 34 | display: inline-block; 35 | width: 15px; 36 | height: 15px; 37 | border-radius: 50%; 38 | /* A little hack to display the window buttons in one pseudo element. */ 39 | background: #d9515d; 40 | -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 41 | box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; 42 | } 43 | 44 | [data-termynal]:after { 45 | content: 'bash'; 46 | position: absolute; 47 | color: var(--color-text-subtle); 48 | top: 5px; 49 | left: 0; 50 | width: 100%; 51 | text-align: center; 52 | } 53 | 54 | [data-ty] { 55 | display: block; 56 | line-height: 2; 57 | } 58 | 59 | [data-ty]:before { 60 | /* Set up defaults and ensure empty lines are displayed. */ 61 | content: ''; 62 | display: inline-block; 63 | vertical-align: middle; 64 | } 65 | 66 | [data-ty="input"]:before, 67 | [data-ty-prompt]:before { 68 | margin-right: 0.75em; 69 | color: var(--color-text-subtle); 70 | } 71 | 72 | [data-ty="input"]:before { 73 | content: '$'; 74 | } 75 | 76 | [data-ty][data-ty-prompt]:before { 77 | content: attr(data-ty-prompt); 78 | } 79 | 80 | [data-ty-cursor]:after { 81 | content: attr(data-ty-cursor); 82 | font-family: monospace; 83 | margin-left: 0.5em; 84 | -webkit-animation: blink 1s infinite; 85 | animation: blink 1s infinite; 86 | } 87 | 88 | 89 | /* Cursor animation */ 90 | 91 | @-webkit-keyframes blink { 92 | 50% { 93 | opacity: 0; 94 | } 95 | } 96 | 97 | @keyframes blink { 98 | 50% { 99 | opacity: 0; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /docs/assets/termynal/termynal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * termynal.js 3 | * A lightweight, modern and extensible animated terminal window, using 4 | * async/await. 5 | * 6 | * @author Ines Montani 7 | * @version 0.0.1 8 | * @license MIT 9 | */ 10 | 11 | 'use strict'; 12 | 13 | /** Generate a terminal widget. */ 14 | class Termynal { 15 | /** 16 | * Construct the widget's settings. 17 | * @param {(string|Node)=} container - Query selector or container element. 18 | * @param {Object=} options - Custom settings. 19 | * @param {string} options.prefix - Prefix to use for data attributes. 20 | * @param {number} options.startDelay - Delay before animation, in ms. 21 | * @param {number} options.typeDelay - Delay between each typed character, in ms. 22 | * @param {number} options.lineDelay - Delay between each line, in ms. 23 | * @param {number} options.progressLength - Number of characters displayed as progress bar. 24 | * @param {string} options.progressChar – Character to use for progress bar, defaults to █. 25 | * @param {number} options.progressPercent - Max percent of progress. 26 | * @param {string} options.cursor – Character to use for cursor, defaults to ▋. 27 | * @param {Object[]} lineData - Dynamically loaded line data objects. 28 | * @param {boolean} options.noInit - Don't initialise the animation. 29 | */ 30 | constructor(container = '#termynal', options = {}) { 31 | this.container = (typeof container === 'string') ? document.querySelector(container) : container; 32 | this.pfx = `data-${options.prefix || 'ty'}`; 33 | this.startDelay = options.startDelay 34 | || parseFloat(this.container.getAttribute(`${this.pfx}-startDelay`)) || 600; 35 | this.typeDelay = options.typeDelay 36 | || parseFloat(this.container.getAttribute(`${this.pfx}-typeDelay`)) || 90; 37 | this.lineDelay = options.lineDelay 38 | || parseFloat(this.container.getAttribute(`${this.pfx}-lineDelay`)) || 1500; 39 | this.progressLength = options.progressLength 40 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressLength`)) || 40; 41 | this.progressChar = options.progressChar 42 | || this.container.getAttribute(`${this.pfx}-progressChar`) || '█'; 43 | this.progressPercent = options.progressPercent 44 | || parseFloat(this.container.getAttribute(`${this.pfx}-progressPercent`)) || 100; 45 | this.cursor = options.cursor 46 | || this.container.getAttribute(`${this.pfx}-cursor`) || '▋'; 47 | this.lineData = this.lineDataToElements(options.lineData || []); 48 | if (!options.noInit) this.init() 49 | } 50 | 51 | /** 52 | * Initialise the widget, get lines, clear container and start animation. 53 | */ 54 | init() { 55 | // Appends dynamically loaded lines to existing line elements. 56 | this.lines = [...this.container.querySelectorAll(`[${this.pfx}]`)].concat(this.lineData); 57 | 58 | /** 59 | * Calculates width and height of Termynal container. 60 | * If container is empty and lines are dynamically loaded, defaults to browser `auto` or CSS. 61 | */ 62 | const containerStyle = getComputedStyle(this.container); 63 | this.container.style.width = containerStyle.width !== '0px' ? 64 | containerStyle.width : undefined; 65 | this.container.style.minHeight = containerStyle.height !== '0px' ? 66 | containerStyle.height : undefined; 67 | 68 | this.container.setAttribute('data-termynal', ''); 69 | this.container.innerHTML = ''; 70 | this.start(); 71 | } 72 | 73 | /** 74 | * Start the animation and rener the lines depending on their data attributes. 75 | */ 76 | async start() { 77 | await this._wait(this.startDelay); 78 | 79 | for (let line of this.lines) { 80 | const type = line.getAttribute(this.pfx); 81 | const delay = line.getAttribute(`${this.pfx}-delay`) || this.lineDelay; 82 | 83 | if (type == 'input') { 84 | line.setAttribute(`${this.pfx}-cursor`, this.cursor); 85 | await this.type(line); 86 | await this._wait(delay); 87 | } 88 | 89 | else if (type == 'progress') { 90 | await this.progress(line); 91 | await this._wait(delay); 92 | } 93 | 94 | else { 95 | this.container.appendChild(line); 96 | await this._wait(delay); 97 | } 98 | 99 | line.removeAttribute(`${this.pfx}-cursor`); 100 | } 101 | } 102 | 103 | /** 104 | * Animate a typed line. 105 | * @param {Node} line - The line element to render. 106 | */ 107 | async type(line) { 108 | const chars = [...line.textContent]; 109 | const delay = line.getAttribute(`${this.pfx}-typeDelay`) || this.typeDelay; 110 | line.textContent = ''; 111 | this.container.appendChild(line); 112 | 113 | for (let char of chars) { 114 | await this._wait(delay); 115 | line.textContent += char; 116 | } 117 | } 118 | 119 | /** 120 | * Animate a progress bar. 121 | * @param {Node} line - The line element to render. 122 | */ 123 | async progress(line) { 124 | const progressLength = line.getAttribute(`${this.pfx}-progressLength`) 125 | || this.progressLength; 126 | const progressChar = line.getAttribute(`${this.pfx}-progressChar`) 127 | || this.progressChar; 128 | const chars = progressChar.repeat(progressLength); 129 | const progressPercent = line.getAttribute(`${this.pfx}-progressPercent`) 130 | || this.progressPercent; 131 | line.textContent = ''; 132 | this.container.appendChild(line); 133 | 134 | for (let i = 1; i < chars.length + 1; i++) { 135 | await this._wait(this.typeDelay); 136 | const percent = Math.round(i / chars.length * 100); 137 | line.textContent = `${chars.slice(0, i)} ${percent}%`; 138 | if (percent>progressPercent) { 139 | break; 140 | } 141 | } 142 | } 143 | 144 | /** 145 | * Helper function for animation delays, called with `await`. 146 | * @param {number} time - Timeout, in ms. 147 | */ 148 | _wait(time) { 149 | return new Promise(resolve => setTimeout(resolve, time)); 150 | } 151 | 152 | /** 153 | * Converts line data objects into line elements. 154 | * 155 | * @param {Object[]} lineData - Dynamically loaded lines. 156 | * @param {Object} line - Line data object. 157 | * @returns {Element[]} - Array of line elements. 158 | */ 159 | lineDataToElements(lineData) { 160 | return lineData.map(line => { 161 | let div = document.createElement('div'); 162 | div.innerHTML = `${line.value || ''}`; 163 | 164 | return div.firstElementChild; 165 | }); 166 | } 167 | 168 | /** 169 | * Helper function for generating attributes string. 170 | * 171 | * @param {Object} line - Line data object. 172 | * @returns {string} - String of attributes. 173 | */ 174 | _attributes(line) { 175 | let attrs = ''; 176 | for (let prop in line) { 177 | attrs += this.pfx; 178 | 179 | if (prop === 'type') { 180 | attrs += `="${line[prop]}" ` 181 | } else if (prop !== 'value') { 182 | attrs += `-${prop}="${line[prop]}" ` 183 | } 184 | } 185 | 186 | return attrs; 187 | } 188 | } 189 | 190 | /** 191 | * HTML API: If current script has container(s) specified, initialise Termynal. 192 | */ 193 | if (document.currentScript.hasAttribute('data-termynal-container')) { 194 | const containers = document.currentScript.getAttribute('data-termynal-container'); 195 | containers.split('|') 196 | .forEach(container => new Termynal(container)) 197 | } 198 | -------------------------------------------------------------------------------- /docs/examples/hello-rich.py: -------------------------------------------------------------------------------- 1 | import rich 2 | 3 | 4 | def hi(name: str): 5 | """Greet the user.""" 6 | rich.print(f'Hello [red]{name}[/red]!') 7 | -------------------------------------------------------------------------------- /docs/examples/hello-typer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | import typer 4 | 5 | 6 | class GreetingStyle(Enum): 7 | """Style of the greeting.""" 8 | 9 | ENGLISH = 'en' 10 | ITALIAN = 'it' 11 | 12 | 13 | def hi( 14 | name: str = typer.Argument( 15 | ..., 16 | help='Name of the one we would like to greet.', 17 | envvar='NAME_TO_GREET', 18 | ), 19 | style: GreetingStyle = typer.Option( 20 | GreetingStyle.ENGLISH, 21 | help='Style of the greeting.', 22 | ), 23 | ): 24 | """Greet the user.""" 25 | greeting = { 26 | GreetingStyle.ENGLISH: 'Hello', 27 | GreetingStyle.ITALIAN: 'Buongiorno', 28 | }[style] 29 | 30 | typer.echo(f'{greeting} {name}!') 31 | -------------------------------------------------------------------------------- /docs/examples/hello.py: -------------------------------------------------------------------------------- 1 | def hi(name: str): 2 | """Greet the user.""" 3 | print(f'Hello {name}!') 4 | -------------------------------------------------------------------------------- /docs/examples/hidden.py: -------------------------------------------------------------------------------- 1 | def _format_greeting(name: str) -> str: 2 | return f'Hello {name}!' 3 | 4 | 5 | def hi(name: str): 6 | """Greet the user.""" 7 | print(_format_greeting(name)) 8 | -------------------------------------------------------------------------------- /docs/examples/homepage.py: -------------------------------------------------------------------------------- 1 | import rich 2 | import sh 3 | 4 | 5 | def hi(): 6 | """Hello world.""" 7 | user_name = sh.whoami() 8 | machine = sh.uname('-a') 9 | 10 | rich.print(f'Hello [b]{user_name}[/b]!') 11 | rich.print(f'This code is running on: [b]{machine}[/b].') 12 | -------------------------------------------------------------------------------- /docs/examples/shell.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from sh import grep, pip 3 | 4 | 5 | def list_flake8_plugins(): 6 | """List installed plugins for Flake8.""" 7 | typer.echo( 8 | grep( # (1)! 9 | 'flake8-', 10 | _in=pip.freeze(), 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to jeeves! 3 | hello: examples/hello.py 4 | hide: 5 | - toc 6 | - navigation 7 | --- 8 | 9 | # :material-bow-tie: Welcome to Jeeves! 10 | 11 | === "pip" 12 | 13 | ```bash 14 | pip install 'jeeves-shell[all]' 15 | ``` 16 | 17 | === "poetry" 18 | 19 | ```bash 20 | poetry add --group dev --extras=all jeeves-shell 21 | ``` 22 | 23 |
24 | 25 | {{ code("examples/homepage.py", language='python', title='jeeves.py') }} 26 | 27 |
28 | ls 29 | jeeves.py 30 | j hi 31 | Hello john-connor! 32 | This code is running on: Cyberdyne T800! 33 |
34 | 35 |
36 | 37 | # Features 38 | 39 | !!! info inline end "" 40 | ![](assets/cover-original.png) 41 | 42 |
43 | 44 | - :fontawesome-solid-terminal:{ .lg .middle } __Build custom shell commands__ 45 | 46 | --- 47 | 48 | …to build, compile, lint, format, test, deploy, and :rocket: otherwise propel your project. 49 | 50 | - :material-tools:{ .lg .middle } __`make` → `j`__ 51 | 52 | --- 53 | 54 | Single entry point to all your custom commands. 55 | 56 | - :simple-python:{ .lg .middle } __`Makefile` → `jeeves.py`__ 57 | 58 | --- 59 | 60 | Write your workflows @ Python programming language. 61 | 62 | [:octicons-arrow-right-24: `jeeves.py`](jeeves-py) 63 | 64 | - :material-typewriter:{ .lg .middle } __Based on Typer__ 65 | 66 | --- 67 | 68 | Brings documentation, arguments, options, validation & more. 69 | 70 | [:octicons-arrow-right-24: Typer](typer) 71 | 72 | - :material-battery:{ .lg .middle } __Batteries__ 73 | 74 | --- 75 | 76 | Execute shell commands & format output. 77 | 78 | [:octicons-arrow-right-24: `sh`](sh) & [`rich`](rich) 79 | 80 | - :fontawesome-solid-plug:{ .lg .middle } __Plugins__ 81 | 82 | --- 83 | 84 | Share your setup among projects. 85 | 86 | [:octicons-arrow-right-24: Plugins](plugins/why) 87 | 88 |
89 | 90 |

91 | See [:material-file-document-edit: The tutorial](jeeves-py){ .md-button } for details :smirk_cat: 92 |

93 | 94 | 95 | # :material-phone-in-talk: Let's talk 96 | 97 |
98 | 99 | - :material-bug:{ .lg .middle } __Bug? Feature request?__ 100 | 101 | --- 102 | 103 | [:heavy_plus_sign: Submit an issue!](https://github.com/jeeves-sh/jeeves-shell/issues/new) 104 | 105 | 106 | - :fontawesome-solid-ellipsis:{ .lg .middle } __Anything else?__ 107 | 108 | --- 109 | 110 | See my site: [:material-web: yeti.sh](https://yeti.sh) 111 | 112 | 113 |
114 | 115 | 116 | -------------------------------------------------------------------------------- /docs/jeeves-py.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: jeeves.py 3 | hello: examples/hello.py 4 | hide: 5 | - toc 6 | --- 7 | 8 | # :simple-python: jeeves.py 9 | 10 | {# todo: draw a picture where Makefile is crossed away and jeeves.py is shown instead #} 11 | 12 | While :simple-gnu: GNU Make goals are specified in a file named `Makefile`, Jeeves looks for commands in a file named `jeeves.py` in current directory. 13 | 14 | ## Commands 15 | 16 | Every Python function in `jeeves.py` is converted into a command. 17 | 18 | * Docstrings are used as command documentation, 19 | * Command arguments are configured by arguments of respective functions (and their type hints!) 20 | 21 | Let's look at a simple Hello World script. 22 | 23 | {{ code(page.meta.hello, language='python', title='jeeves.py') }} 24 | 25 | Check out how automated documentation works: 26 | 27 | {{ j(page.meta.hello, environment={'JEEVES_DISABLE_PLUGINS': 'true'}) }} 28 | 29 | Or, for the given command: 30 | 31 | {{ j(page.meta.hello, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['hi', '--help']) }} 32 | 33 | Let's run it: 34 | 35 | {{ j(page.meta.hello, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['hi', 'John']) }} 36 | -------------------------------------------------------------------------------- /docs/plugins/.pages: -------------------------------------------------------------------------------- 1 | nav: 2 | - why.md 3 | - ... 4 | -------------------------------------------------------------------------------- /docs/plugins/how-to.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Your new jeeves-super-plugin 3 | hello: examples/hello.py 4 | --- 5 | 6 | # :fontawesome-solid-plug-circle-plus: Your new `jeeves-super-plugin` 7 | 8 | * Create a Python 3.10+ virtual env for your new plugin project 9 | * `pip install -U pip poetry` 10 | * `poetry init` 11 | * `poetry add jeeves-shell --extras=all` 12 | * Write into `pyproject.toml`: 13 | 14 | ```toml 15 | [tool.poetry.plugins.jeeves] 16 | super-plugin = "jeeves_super_plugin:app" 17 | ``` 18 | 19 | * Create your Typer instance: 20 | 21 | ```python title="jeeves_super_plugin/__init__.py" 22 | import typer 23 | 24 | app = typer.Typer(no_args_is_help=True) 25 | 26 | @app.command() 27 | def hi(name: str): 28 | """Greet the user.""" 29 | 30 | @app.command() 31 | def bye(): 32 | """Bid farewell.""" 33 | ``` 34 | 35 | * `poetry install` 36 | * `j super-plugin` 37 | 38 | You should see `hi` and `bye` as available commands. 39 | 40 | ## Special plugin name: `__root__` 41 | 42 | ```toml 43 | [tool.poetry.plugins.jeeves] 44 | __root__ = "jeeves_super_plugin:app" 45 | ``` 46 | 47 | This will mean that your plugin _replaces_ the default root Jeeves app, and your commands will be available as 48 | 49 | * `j hi` instead of `j super-plugin hi` 50 | * `j bye` instead of `j super-plugin bye`. 51 | 52 | This is useful for plugins like `jeeves-yeti-pyproject`, to make the commands you very often use, like `lint`, faster to type. 53 | -------------------------------------------------------------------------------- /docs/plugins/why.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Why plugins? 3 | --- 4 | 5 | # :fontawesome-solid-plug: Why plugins? 6 | 7 | {# todo: Share these use cases with the home page of the program #} 8 | 9 | === ":material-account-tie: For a company" 10 | 11 | You have multiple projects/repositories, and you have very similar or identical deployment, testing, and linting rules. 12 | 13 | === ":person_curly_hair: For an individual" 14 | 15 | You have multiple open source repositories, and you would like to maintain the same standards for each and avoid tedious and repeated setup tasks for them. 16 | 17 | ## What is a plugin? 18 | 19 | `jeeves` plugin is an installable Python package which declares `jeeves` commands. You do not need to copy `jeeves.py` from one project to another; you can define a plugin with reusable commands, and install that plugin as dev dependency. 20 | 21 | ## Plugin example: `jeeves-yeti-pyproject` 22 | 23 | This is a custom plugin created to manage my own open source projects. 24 | 25 | * It brings `pytest` and a few plugins for it as dependencies; `j test` command tests Python code exactly as I like it to be tested 26 | * It also has a number of linters and `flake8` plugins, `j lint` uses a shared configuration for those 27 | * I no longer have to install all those dev dependencies for every new project and to copy-paste their configuration files 28 | * If one plugin conflicts with another, I resolve the issue only once and then just `poetry update` all my projects 29 | 30 | ```shell 31 | poetry add --group dev jeeves-yeti-pyproject 32 | ``` 33 | 34 | ## Top level documentation 35 | 36 | {{ j(None) }} 37 | -------------------------------------------------------------------------------- /docs/project/decisions/.pages: -------------------------------------------------------------------------------- 1 | nav: 2 | - start-jeeves-project.md 3 | - shell-combinators.md 4 | -------------------------------------------------------------------------------- /docs/project/decisions/shell-combinators.md: -------------------------------------------------------------------------------- 1 | --- 2 | $id: shell-combinators 3 | title: Use sh for shell combinators 4 | hide: 5 | - toc 6 | 7 | schema:wasDerivedFrom: https://lab.abilian.com/Tech/Python/Shell%20in%20Python/ 8 | 9 | table:columns: 10 | - table:self 11 | - $id: import-hook 12 | rdfs:comment: "`from some_library import ls, ssh, aws` is extremely convenient." 13 | - $id: native-kwargs 14 | rdfs:comment: Convert Python keyword arguments to shell --arguments. 15 | - $id: minimalism 16 | title: Minimalism Score 17 | - $id: return-code-exceptions 18 | rdfs:title: Return code exceptions 19 | rdfs:comment: Separate exception class for each return code. 20 | - $id: partial-commands 21 | rdfs:comment: Concise functools.partial() support for shell commands. 22 | - $id: subcommands 23 | rdfs:comment: Support to easily compose subcommands like `git fetch`. 24 | - $id: execution-contexts 25 | title: Execution Context 26 | rdfs:comment: Execution context allows to concisely pipe output for all commands in a group to the same stream, for example, without providing the same set of options to each command. 27 | - notes 28 | 29 | table:rows: 30 | - schema:url: https://github.com/tomerfiliba/plumbum 31 | rdfs:label: tomerfiliba/plumbum 32 | import-hook: yes 33 | native-kwargs: no 34 | partial-commands: yes 35 | separate-exception-per-return-code: no 36 | subcommands: yes 37 | minimalism: 1 38 | execution-contexts: no 39 | notes: Does shell coloring and building of CLI applications 40 | 41 | - schema:url: https://github.com/amoffat/sh 42 | rdfs:label: amoffat/sh 43 | import-hook: yes 44 | minimalism: 3 45 | native-kwargs: yes 46 | separate-exception-per-return-code: yes 47 | partial-commands: yes 48 | subcommands: yes 49 | execution-contexts: yes 50 | 51 | - schema:url: https://github.com/aeroxis/sultan 52 | rdfs:label: aeroxis/sultan 53 | import-hook: no 54 | 55 | - schema:url: https://github.com/elcaminoreal/seashore 56 | rdfs:label: elcaminoreal/seashore 57 | import-hook: no 58 | 59 | - schema:url: https://github.com/dgilland/shelmet 60 | rdfs:label: dgilland/shelmet 61 | import-hook: no 62 | --- 63 | 64 | # Use `sh` for shell combinators 65 | 66 | {# todo: mkdocs-iolanta must render table in the page body itself #} 67 | {# todo: mark sh in green #} 68 | {# todo: import last release date and stars count from GitHub #} 69 | 70 | {{ render("shell-combinators") }} 71 | -------------------------------------------------------------------------------- /docs/project/decisions/start-jeeves-project.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Start Jeeves project" 3 | hide: 4 | - toc 5 | 6 | $id: jeeves-alternatives 7 | table:columns: 8 | - table:self 9 | - language 10 | - last-release 11 | - command-name 12 | - $id: cli-commands 13 | rdfs:label: Engine for CLI commands 14 | - $id: plugins 15 | rdfs:label: Plugin support 16 | - $id: dependencies 17 | rdfs:label: Dependencies system 18 | - notes 19 | 20 | table:rows: 21 | - rdfs:label: GNU Make 22 | language: make + bash 23 | dependencies: yes 24 | plugins: no 25 | command-name: make 26 | 27 | - schema:url: https://pythonhosted.org/Paver 28 | rdfs:label: Paver 29 | language: python 30 | dependencies: yes 31 | plugins: no 32 | last-release: 2017-12-31 33 | cli-commands: Own syntax with decorators 34 | command-name: paver 35 | notes: Envelops setuptools and distutils, conflicting with Poetry. 36 | 37 | - schema:url: https://www.pyinvoke.org 38 | rdfs:label: invoke 39 | language: python 40 | last-release: 2023-05-16 41 | dependencies: yes 42 | cli-commands: "Own syntax with @task decorators" 43 | command-name: invoke 44 | notes: Has tools to define CLI commands (without type hints support though) and calling commands. Subjectively — too much is stuffed into one single package. 45 | 46 | - schema:url: https://scons.org 47 | rdfs:label: scons 48 | language: python 49 | last-release: 2023-03-21 50 | dependencies: yes 51 | cli-commands: No way to define custom commands 52 | command-name: scons 53 | 54 | - schema:url: https://github.com/basherpm/basher 55 | rdfs:label: basher 56 | language: bash 57 | 58 | - schema:url: https://github.com/python-manage/manage 59 | language: python 60 | last-release: 2021-02-07 61 | rdfs:label: manage 62 | dependencies: no 63 | cli-commands: 64 | $id: click 65 | schema:url: https://click.palletsprojects.com 66 | plugins: no 67 | command-name: manage 68 | 69 | - schema:url: https://github.com/jeeves-sh/jeeves-shell 70 | rdfs:label: jeeves 71 | language: python 72 | last-release: 2023-05-19 73 | dependencies: no 74 | cli-commands: 75 | schema:url: https://tiangolo.com/typer 76 | rdfs:label: typer 77 | plugins: yes 78 | command-name: j 79 | --- 80 | 81 | # :rocket: Start Jeeves project 82 | 83 | ## Context 84 | 85 | There are a few alternatives available, none of which I 100% like. 86 | 87 | {# todo: describe google Fire as alternative #} 88 | 89 | {{ render("jeeves-alternatives") }} 90 | 91 | ## Decision 92 | 93 | Create a new project — the documentation for which you are reading ☺ 94 | -------------------------------------------------------------------------------- /docs/project/decisions/unpack.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Unpack a dataclass or TypedDict → Typer command arguments 3 | --- 4 | 5 | # `Unpack[]` a dataclass or TypedDict → Typer command arguments 6 | 7 | ## Context 8 | 9 | When designing command-line interfaces with Typer, we often encounter scenarios where multiple commands share similar or identical sets of arguments. To avoid redundancy and improve code maintainability, we are considering using the Unpack[] type hint, which would allow us to unpack fields from a `dataclass` or `TypedDict` directly into command arguments. 10 | 11 | !!! info "Votum Separatum: Callbacks" 12 | Functions that accept multiple common parameters are often united under one Typer sub-application. This allows for the use of callback functions and Typer context to handle shared parameters. 13 | 14 | `typing.Unpack` was introduced in [PEP 646 Variadic Generics](https://peps.python.org/pep-0646/) and then further extended in [PEP 692 Using TypedDict for more precise **kwargs typing](https://peps.python.org/pep-0692/#using-unpack-with-types-other-than-typeddict). We can make use of that. 15 | 16 | ## Decision 17 | 18 | We will use the `Unpack[]` type hint to unpack fields from dataclasses or TypedDicts and map them directly to Typer command arguments. 19 | 20 | Example: 21 | 22 | ```python 23 | from typing import Unpack 24 | from dataclasses import dataclass 25 | from typer import Typer 26 | 27 | @dataclass 28 | class Person: 29 | """Person.""" 30 | 31 | first_name: str 32 | last_name: str 33 | 34 | app = Typer() 35 | 36 | @app.command() 37 | def process_person(person: Unpack[Person]): 38 | """Do something with a person.""" 39 | ``` 40 | 41 | This approach allows users to provide each attribute of `Person` as a separate argument: `command --first-name John --last-name Doe`. 42 | 43 | 44 | ## Consequences 45 | 46 | We will need to implement this for Typer mainstream code base. 47 | 48 | * Source code: [`def get_params_from_function`](https://github.com/tiangolo/typer/blob/master/typer/utils.py#L108) function, 49 | * which needs to be refactored a bit and generalized, 50 | * and expanded to support `Unpack`. 51 | * There is a [GitHub issue](https://github.com/tiangolo/typer/issues/154) which might contain relevant research. 52 | -------------------------------------------------------------------------------- /docs/project/roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | 5 | roadmap:roadmap: 6 | - title: Use rich logger 7 | - title: Implement __rich__ at documented errors and warnings 8 | - title: console.print() an exception instead of logging it in jeeves 9 | - title: Custom Console for printing errors with red headings 10 | - title: Use rich for printing errors when available and plain print when not 11 | 12 | - title: "Fork pull request: `j fork`" 13 | is-blocked-by: 14 | - title: There are uncommitted changes 15 | - title: Ask for branch name 16 | - title: "Do `git switch -c`" 17 | - title: "Call `j commit` to save the changes" 18 | - title: "Call `gh pr create`" 19 | 20 | - title: "Invent a commit message with LLM at `j commit`" 21 | is-blocked-by: 22 | - title: Customize branch and commit message patterns via command line options & pyproject.toml 23 | - title: "Measure PR complexity at `j commit` & alert about the PR being too complex" 24 | 25 | - title: LockingPath class 26 | - title: How to pass the locking instance of project directory? 27 | branches: 28 | - title: Via dependencies 29 | - title: Via Typer context 30 | 31 | - title: Publish and document jeeves-generate-mkdocs-material-insiders 32 | is-blocked-by: jeeves-generate 33 | 34 | - title: jeeves → social media 35 | "$id": publish 36 | branches: 37 | - title: HN 38 | - title: dev.to 39 | - title: … 40 | 41 | is-blocked-by: 42 | - title: "`j generate` @ `jeeves-yeti-pyproject`" 43 | is-blocked-by: 44 | - title: Generate GitHub workflows 45 | - title: docs/index.md | iolanta-jinja2 → README.md 46 | - title: poetry lock --no-update 47 | - title: … Any other generation commands? 48 | 49 | - title: What do we call when we want to process the DAG? 50 | branches: 51 | - title: DAG itself 52 | description: Would be preferable for the jeeves-generate case, and is most similar to Step Functions 53 | - title: One of functions of the DAG 54 | 55 | - title: Implement CI mode for jeeves-generate DAG 56 | description: Stop As soon as a task fails 57 | - title: Use the collected statistics of tasks performance to determine which tasks to run first 58 | is-blocked-by: 59 | - title: Record the statistics of tasks performance 60 | - title: Publish dry.jeeves.sh 61 | description: Transparent dry run functionality is important, that's what Make does not have 62 | is-blocked-by: 63 | - title: jeeves-dry → PyPI 64 | is-blocked-by: 65 | - title: Update main Jeeves app 66 | - title: Add dry-run argument to the Jeeves app 67 | - title: Post-process iterators as commands 68 | - title: Publish generate.jeeves.sh 69 | "$id": jeeves-generate 70 | is-blocked-by: 71 | - title: jeeves-generate → PyPI 72 | is-blocked-by: 73 | - title: Do not generate anything if there are uncommitted changes 74 | - title: How to build a DAG? 75 | branches: 76 | - title: Use an existing library 77 | - title: Build a new dependencies DAG library 78 | is-blocked-by: 79 | - title: Get dependencies from Annotated 80 | - title: Get dependencies from decorator arguments 81 | is-blocked-by: 82 | - title: Compare Python dependencies libraries 83 | is-blocked-by: 84 | - title: I want iolanta-prov to finally write good comparisons 85 | blocks: 86 | - title: Add Edit button to jeeves.sh 87 | blocks: 88 | - title: Show my dependencies comparison to Artem 89 | 90 | - https://github.com/jeeves-sh/jeeves-shell/issues/15 91 | 92 | - title: On Typer card, say why Typer is cool 93 | - title: Document why we chose Typer 94 | is-blocked-by: 95 | - title: In alternatives tables, highlight the chosen alternative with green background 96 | 97 | - title: Document which license Jeeves is using 98 | is-blocked-by: 99 | - https://github.com/jeeves-sh/jeeves-shell/issues/18 100 | 101 | - title: Badges in README.md are in bad shape 102 | is-blocked-by: 103 | - title: Tests are failing @ CI 104 | bug: true 105 | 106 | - title: Implement & use Side By Side display with cards 107 | 108 | - title: Document use cases 109 | 110 | - title: Generate Github workflows at jeeves-yeti-pyproject 111 | is-blocked-by: 112 | - title: "Implement `jeeves boilerplate`" 113 | branches: 114 | - title: "Run tasks registered as plugins to jeeves-boilerplate" 115 | - title: "Somehow with Cookiecutter?" 116 | 117 | - title: Sync jeeves.py example with home page example 118 | is-blocked-by: 119 | - https://github.com/jeeves-sh/jeeves-shell/issues/19 120 | 121 | - title: Make social cards 122 | - title: sh command streams decorator — where? 123 | branches: 124 | - title: Part of Jeeves 125 | - title: Part of sh 126 | - title: Separate package 127 | is-blocked-by: 128 | - title: progress bars when executing sh commands 129 | - title: Support dependencies 130 | is-blocked-by: 131 | - title: Review awesome-python-dependency-injection 132 | - title: Document alternatives 133 | - title: pre-conditions perhaps 134 | 135 | - title: Build a Plugins page with catalog 136 | is-blocked-by: 137 | - title: Support cards in mkdocs-iolanta 138 | - title: Document jeeves-yeti-pyproject 139 | branches: 140 | - title: At jeeves.sh 141 | - title: At GitHub pages 142 | is-blocked-by: 143 | - title: jeeves-yeti-pyproject only scans and formats python packages, not individual files like jeeves.py 144 | bug: true 145 | - title: Move jeeves-yeti-pyproject → jeeves-sh org? 146 | branches: 147 | - title: yes 148 | - title: no 149 | - title: See if there is a tool to document Click with mkdocs 150 | - title: Terminal output from jeeves-yeti-pyproject is broken 151 | bug: true 152 | description: Look at TERM environment variable at CI 153 | 154 | 155 | 156 | - title: "`j fork 123` @ `jeeves-yeti-pyproject`" 157 | is-blocked-by: 158 | - title: Create the PR with proper base and title 159 | is-blocked-by: 160 | - title: Commit 161 | is-blocked-by: 162 | - title: switch -c to the proper branch 163 | is-blocked-by: 164 | - title: Format branch name 165 | is-blocked-by: 166 | - title: Retrieve issue info or create an issue 167 | is-blocked-by: 168 | - title: "Issue `j fork` with issue arg or without" 169 | is-blocked-by: 170 | - title: Do some uncommitted changes 171 | 172 | - title: Convert j → self-contained binary 173 | 174 | - title: Some kind of Python conference at 2023 175 | $id: presentation 176 | is-blocked-by: 177 | title: Prepare a talk about Jeeves 178 | $id: jeeves-talk 179 | is-blocked-by: 180 | - title: Reload the site when a template is edited 181 | - title: File is deleted ⇒ dev server crashes 182 | bug: true 183 | - title: Review chainlit 184 | schema:url: https://github.com/Chainlit/chainlit 185 | - title: Does jeeves work well with Bash completion? 186 | - title: Document jeeves vs cookiecutter and stuff 187 | is-blocked-by: 188 | - title: project templates 189 | - title: Make a presentation or a video 190 | - title: parallel decorator 191 | 192 | - title: Mount points 193 | description: for instance, jeeves-mkdocs should be always a subcommand 194 | is-blocked-by: 195 | - title: How to define mount points 196 | branches: 197 | - title: in the plugin 198 | - title: in the project 199 | - title: both 200 | - title: mkdocs-typer 201 | description: Describe Typer docs → MkDocs site 202 | is-blocked-by: 203 | - title: "Use Iolanta for mkdocs-typer?" 204 | branches: 205 | - title: Yes 206 | - title: No 207 | 208 | - title: Jeeves + UI 209 | is-blocked-by: 210 | - title: Review editors which Jeeves might integrate with 211 | - title: How can Jeeves be integrated with i3 212 | 213 | - title: Name Jupyter notebooks after branch 214 | - title: PyCharm does not recognize sh imports 215 | bug: true 216 | - title: coverage is failing 217 | bug: true 218 | - title: _archive directory is just chilling there 219 | bug: true 220 | 221 | - title: Source environment variables from Typer 222 | - title: Document custom environment variables 223 | branches: 224 | - title: Annotate functions with JSON-LD or direct RDF 225 | - title: Just write custom YAML-LD 226 | --- 227 | 228 | {{ render("publish") }} 229 | -------------------------------------------------------------------------------- /docs/reference/environment-variables/JEEVES_ROOT.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: JEEVES_ROOT 3 | --- 4 | 5 | !!! info "Available since: 2.1.10" 6 | 7 | Environment variable to define directory containing [jeeves.py](../jeeves-py) or [jeeves package](../jeeves-package). 8 | 9 | ```shell title="ls /home/someone/somewhere" 10 | jeeves.py 11 | … 12 | ``` 13 | 14 | ⇒ 15 | 16 | ```shell 17 | JEEVES_ROOT=/home/someone/somewhere j 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/reference/environment-variables/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Environment variables 3 | 4 | $id: environment-variables 5 | table:columns: 6 | - table:self 7 | - default 8 | - purpose 9 | 10 | table:rows: 11 | - rdfs:label: JEEVES_ROOT 12 | schema:url: JEEVES_ROOT/ 13 | default: (current directory) 14 | purpose: Location of jeeves.py file or jeeves package. 15 | - rdfs:label: JEEVES_DISABLE_PLUGINS 16 | default: (unset) 17 | purpose: Set this to ignore any installed plugins (and thus only use jeeves.py or jeeves package commands). Might be useful for debugging. 18 | --- 19 | 20 | # :material-application-variable: Environment variables 21 | 22 | {{ render("environment-variables") }} 23 | -------------------------------------------------------------------------------- /docs/reference/options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Options 3 | 4 | $id: options 5 | table:columns: 6 | - table:self 7 | - default 8 | - purpose 9 | 10 | table:rows: 11 | - rdfs:label: --log-level 12 | default: error 13 | purpose: Logging level. Use info or debug for more detailed logging and to print tracebacks. 14 | --- 15 | 16 | # :material-apple-keyboard-option: Options 17 | 18 | {{ render("options") }} 19 | -------------------------------------------------------------------------------- /docs/rich.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pretty output with rich 3 | hello_rich: examples/hello-rich.py 4 | hide: 5 | - toc 6 | --- 7 | 8 | {# todo: Document alternatives to rich such as colorama, maybe reuse my old table from yeti.sh #} 9 | 10 | # :rainbow: Pretty output with `rich` 11 | 12 | [:material-github: textualize/rich](https://github.com/textualize/rich) is a modern library for formatting shell output that we recommend using with `jeeves`. 13 | 14 | !!! success "Installation" 15 | It will be installed as a dependency of `jeeves-shell`. 16 | 17 | ![Rich Logo](https://github.com/textualize/rich/raw/master/imgs/logo.svg) 18 | 19 | {# todo: Vendor Rich logo instead of hot linking it #} 20 | 21 | ## Rich example 22 | 23 | {{ code(page.meta.hello_rich, language='python', title='jeeves.py') }} 24 | 25 | ⇒ 26 | 27 | {{ j(page.meta.hello_rich, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['hi', 'John']) }} 28 | 29 | (Well, this output does not actually convey the effect because `rich` outputs shell markup that's ignored when converting to HTML… Just trust me — it will be red in the terminal, I assure you!) 30 | -------------------------------------------------------------------------------- /docs/sh.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Shell Commands with sh" 3 | sample: examples/shell.py 4 | hide: 5 | - toc 6 | --- 7 | 8 | # :material-bash: Shell Commands with `sh` 9 | 10 | `make` is an orchestrator — it doesn't do much work itself, it calls other commands which do the work. That's what we recommend doing with `jeeves` as well. Among numerous ways to call other programs from Python we recommend a library named, quite concisely, `sh`: [:material-github: amoffat/sh](https://github.com/amoffat/sh). 11 | 12 | !!! info "Installation" 13 | It will be installed as an optional dependency of `jeeves-shell[all]`. 14 | 15 | !!! info "Alternatives" 16 | See [shell cobminators](../project/decisions/shell-combinators/) to check out other available tools. 17 | 18 | {# fixme: rendering `shell-combinators` will render the table inline — not the link to the page. #} 19 | 20 | [![](https://github.com/amoffat/sh/blob/db126f2eec64b8c0b26e557908c296cd159f900a/images/logo-230.png?raw=true)](https://github.com/amoffat/sh) 21 | 22 | {# fixme: "jeeeves-shell[all] does not install `sh` at this point" #} 23 | 24 | By default, it is bundled as a dependency for `jeeves`, and here is a simple example. 25 | 26 | {{ code(page.meta.sample, language='python', title='jeeves.py', annotations=["Equivalent to: `pip freeze | grep flake8-`"]) }} 27 | 28 | Shall we execute it? 29 | 30 | {{ j(page.meta.sample, args=['list-flake8-plugins']) }} 31 | 32 | `sh` allows to call shell commands with conciseness and readability of Pythonic syntax. See [:book: the package docs](http://amoffat.github.io/sh) for more detail. 33 | 34 | {# todo: Document `rich` as terminal UI library with examples #} 35 | -------------------------------------------------------------------------------- /docs/typer.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Typer 3 | hello_with_typer: examples/hello-typer.py 4 | hide: 5 | - toc 6 | --- 7 | 8 | # :material-typewriter: Typer 9 | 10 | Behind the scenes, Jeeves relies upon [Typer](https://typer.tiangolo.com). 11 | 12 | {# fixme: Typer logo is hotlinked, vendor it instead #} 13 | 14 | [![Typer](https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg)](https://typer.tiangolo.com) 15 | 16 | Every function in `jeeves.py` is converted into a Typer [command](https://typer.tiangolo.com/tutorial/commands/). Just as if you did: 17 | 18 | ```python 19 | from typer import Typer 20 | 21 | app = Typer() 22 | 23 | @app.command() 24 | def hi(name: str): 25 | """…""" 26 | 27 | if __name__ == '__main__': 28 | app() 29 | ``` 30 | 31 | * That's one way how Jeeves reduces the boilerplate; 32 | * The other way is to expose all those commands via the `j` shortcut. 33 | 34 | Nonetheless, you still **can** use most Typer features, such as: 35 | 36 | * define `typer.Argument`'s and `typer.Option`'s for your commands, 37 | * validate user input using type hints, 38 | * and much more. 39 | 40 | ## Example: Use Typer features directly 41 | 42 | {# todo: Implement side by side macro, and use it for all code and jeeves examples #} 43 | 44 | {{ code(page.meta.hello_with_typer, language='python', title='jeeves.py') }} 45 | 46 | ⇒ 47 | 48 | {{ j(page.meta.hello_with_typer, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['hi', '--help']) }} 49 | 50 | ## Example: Typer Validation 51 | 52 | {{ j(page.meta.hello_with_typer, environment={'JEEVES_DISABLE_PLUGINS': 'true'}, args=['hi', '--style', 'de']) }} 53 | -------------------------------------------------------------------------------- /jeeves.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import sh 4 | 5 | 6 | def install_mkdocs_insiders(): 7 | """Install Insiders version of `mkdocs-material` theme.""" 8 | name = 'mkdocs-material-insiders' 9 | 10 | if not (Path.cwd() / name).is_dir(): 11 | sh.gh.repo.clone(f'jeeves-sh/{name}') 12 | 13 | sh.pip.install('-e', name) 14 | 15 | 16 | def deploy_to_github_pages(): 17 | """Build the docs & deploy → gh-pages branch.""" 18 | sh.mkdocs('gh-deploy', '--force', '--clean', '--verbose') 19 | 20 | 21 | def install_graphviz(): 22 | """Install graphviz, which is a prerequisite for some helper packages.""" 23 | sh.sudo('apt-get', 'install', '-y', 'graphviz') 24 | 25 | 26 | def serve(): 27 | """Serve documentation locally.""" 28 | sh.mkdocs.serve('-a', 'localhost:8971', _fg=True) 29 | 30 | 31 | def cover_image(): 32 | """Generate cover image for the front page.""" 33 | assets = Path(__file__).parent / 'docs/assets' 34 | sh.convert( 35 | assets / 'cover-original.png', 36 | '-crop', 37 | 'x400+0+100', 38 | assets / 'cover.png', 39 | ) 40 | -------------------------------------------------------------------------------- /jeeves_shell/__init__.py: -------------------------------------------------------------------------------- 1 | from jeeves_shell.jeeves import Jeeves 2 | -------------------------------------------------------------------------------- /jeeves_shell/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | 5 | from rich.console import Console 6 | from rich.errors import NotRenderableError 7 | from rich.panel import Panel 8 | 9 | from jeeves_shell.discover import construct_app 10 | from jeeves_shell.errors import NoCommandsFound, TracebackAdvice, FormattedError 11 | from jeeves_shell.jeeves import Jeeves, LogLevel 12 | 13 | logger = logging.getLogger('jeeves') 14 | 15 | 16 | def execute_app(typer_app: Jeeves): 17 | try: 18 | return typer_app() 19 | 20 | except RuntimeError as err: 21 | raise NoCommandsFound(directory=Path.cwd()) from err 22 | 23 | 24 | def print_unhandled_exception(err: Exception): # pragma: no cover 25 | """Print unhandled exception as an error message.""" 26 | console = Console() 27 | 28 | try: 29 | console.print(Panel(err, style='red')) # type: ignore 30 | except NotRenderableError: 31 | console.print(Panel(FormattedError(exception=err), style='red')) 32 | 33 | console.print(TracebackAdvice()) 34 | 35 | 36 | def app() -> None: # pragma: no cover 37 | """Construct and return Typer app.""" 38 | typer_app = construct_app() 39 | try: 40 | return execute_app(typer_app) 41 | 42 | except Exception as err: 43 | if typer_app.log_level == LogLevel.ERROR: 44 | print_unhandled_exception(err) 45 | sys.exit(1) 46 | 47 | else: 48 | raise 49 | 50 | 51 | if __name__ == '__main__': # pragma: no cover 52 | app() 53 | -------------------------------------------------------------------------------- /jeeves_shell/discover.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import string 4 | import types 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import Annotated, Any, DefaultDict, Iterable, Optional, Tuple, cast 8 | 9 | import funcy 10 | from typer import Option, Typer 11 | 12 | from jeeves_shell.entry_points import entry_points 13 | from jeeves_shell.errors import PluginConflict, UnsuitableRootApp 14 | from jeeves_shell.import_by_path import import_by_path 15 | from jeeves_shell.jeeves import Jeeves, LogLevel 16 | 17 | logger = logging.getLogger('jeeves') 18 | 19 | 20 | PluginsByMountPoint = DefaultDict[str, list[Typer]] 21 | 22 | LogLevelOption = Annotated[LogLevel, Option(help='Logging level.')] 23 | 24 | 25 | def list_installed_plugins() -> PluginsByMountPoint: 26 | """Find installed plugins.""" 27 | if os.getenv('JEEVES_DISABLE_PLUGINS'): # pragma: nocover 28 | return defaultdict(list) 29 | 30 | plugins = [ 31 | (entry_point.name, entry_point.load()) 32 | for entry_point in entry_points(group='jeeves') 33 | ] 34 | 35 | return funcy.group_values(plugins) 36 | 37 | 38 | def construct_root_app(plugins_by_mount_point: PluginsByMountPoint) -> Jeeves: 39 | """Construct root Typer app for Jeeves.""" 40 | root_app_plugins = plugins_by_mount_point.pop('__root__', []) 41 | if not root_app_plugins: 42 | return Jeeves(no_args_is_help=True) 43 | 44 | try: 45 | [root_app] = root_app_plugins 46 | except ValueError: 47 | raise PluginConflict( 48 | mount_point='__root__', 49 | plugins=root_app_plugins, 50 | ) 51 | 52 | return cast(Jeeves, root_app) 53 | 54 | 55 | def _construct_app_from_plugins() -> Jeeves: # pragma: nocover 56 | plugins_by_mount_point = list_installed_plugins() 57 | 58 | root_app = construct_root_app(plugins_by_mount_point) 59 | 60 | for name, plugins_by_name in plugins_by_mount_point.items(): 61 | try: 62 | [plugin] = plugins_by_name 63 | except ValueError: 64 | raise PluginConflict( 65 | mount_point=name, 66 | plugins=plugins_by_name, 67 | ) 68 | 69 | root_app.add_typer( 70 | typer_instance=plugin, 71 | name=name, 72 | ) 73 | 74 | return root_app 75 | 76 | 77 | def _is_function(python_object) -> bool: 78 | return isinstance(python_object, types.FunctionType) 79 | 80 | 81 | def _is_typer(python_object) -> bool: 82 | return isinstance(python_object, Typer) 83 | 84 | 85 | def _is_name_suitable(name: str): 86 | first_character = funcy.first(name) 87 | return first_character not in f'{string.ascii_uppercase}_' 88 | 89 | 90 | def retrieve_commands_from_jeeves_file( # type: ignore # noqa: C901 91 | directory: Path, 92 | ) -> Iterable[Tuple[str, Any]]: 93 | """Convert directory path → sequence of commands & subcommands.""" 94 | if not directory.exists(): 95 | return 96 | 97 | try: 98 | jeeves_module = import_by_path( 99 | path=directory, 100 | ) 101 | 102 | except ImportError as err: # pragma: nocover 103 | # We could not import something. 104 | if err.name == 'jeeves': 105 | # We couldn't import jeeves module. We can skip that. 106 | logger.debug('Module not found: %s', err) 107 | return 108 | 109 | # Something that `jeeves` module is importing can't be imported, that is 110 | # a problem. 111 | raise 112 | 113 | for name, command in vars(jeeves_module).items(): # noqa: WPS421 114 | if not _is_name_suitable(name): 115 | continue 116 | 117 | if _is_function(command) or _is_typer(command): 118 | yield name, command 119 | 120 | 121 | def _augment_app_with_jeeves_file( 122 | app: Jeeves, 123 | path: Path, 124 | ) -> Jeeves: # pragma: nocover 125 | commands = retrieve_commands_from_jeeves_file(path) 126 | for name, command in commands: 127 | if _is_typer(command): 128 | app.add_typer(typer_instance=command, name=name) 129 | else: 130 | app.command()(command) 131 | 132 | return app 133 | 134 | 135 | def _configure_callback(app: Jeeves) -> Jeeves: 136 | def _root_app_callback( # noqa: WPS430 137 | log_level: LogLevelOption = LogLevel.ERROR, 138 | ): # pragma: nocover 139 | app.log_level = log_level 140 | logging.basicConfig( 141 | level={ 142 | LogLevel.ERROR: logging.ERROR, 143 | LogLevel.INFO: logging.INFO, 144 | LogLevel.DEBUG: logging.DEBUG, 145 | }[log_level], 146 | ) 147 | 148 | if app.registered_callback is not None: # pragma: nocover 149 | raise UnsuitableRootApp(app=app) 150 | 151 | app.callback()(_root_app_callback) 152 | return app 153 | 154 | 155 | def construct_app(current_directory: Optional[Path] = None) -> Jeeves: 156 | """Discover plugins and construct a Typer app.""" 157 | if current_directory is None: # pragma: no cover 158 | if directory_from_environment := os.environ.get('JEEVES_ROOT'): 159 | current_directory = Path(directory_from_environment) 160 | else: 161 | current_directory = Path.cwd() 162 | 163 | app = _construct_app_from_plugins() 164 | app = _configure_callback(app) 165 | return _augment_app_with_jeeves_file(app=app, path=current_directory) 166 | -------------------------------------------------------------------------------- /jeeves_shell/entry_points.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 10): # pragma: no cover 4 | from importlib_metadata import entry_points 5 | else: # pragma: no cover 6 | from importlib.metadata import entry_points 7 | -------------------------------------------------------------------------------- /jeeves_shell/errors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | 5 | import funcy 6 | from documented import DocumentedError, Documented 7 | from typer import Typer 8 | 9 | 10 | @dataclass 11 | class NoCommandsFound(DocumentedError): # type: ignore 12 | """ 13 | No Jeeves commands found, and there is nothing to show. 14 | 15 | To get some commands, 16 | - either create a `jeeves.py` file in current directory, 17 | - or install a Jeeves plugin. 18 | 19 | Current directory: {self.directory} 20 | """ 21 | 22 | directory: Path 23 | 24 | 25 | @dataclass 26 | class PluginConflict(DocumentedError): # type: ignore 27 | """ 28 | Conflicting plugins detected. 29 | 30 | Multiple plugins are registered at the same mount point. 31 | 32 | Mount point: {self.mount_point} 33 | Plugins: 34 | {self.plugin_list} 35 | """ 36 | 37 | mount_point: str 38 | plugins: list[Typer] 39 | 40 | @property 41 | def plugin_list(self) -> str: 42 | """Format plugin list.""" 43 | return '\n'.join( 44 | f'• {plugin}' for plugin in self.plugins 45 | ) 46 | 47 | 48 | @dataclass 49 | class UnsuitableRootApp(DocumentedError): # type: ignore 50 | """ 51 | Typer app wants to be used as root Jeeves app but it has a callback. 52 | 53 | Typer app: {self.app} 54 | Registered callback: {self.app.registered_callback} 55 | 56 | Unable to assign standard Jeeves callback to the app because it has one. 57 | """ 58 | 59 | app: Typer 60 | 61 | 62 | @dataclass 63 | class FormattedError(Documented): # type: ignore # pragma: no cover 64 | """**{self.exception_class}:** {self.message}""" # noqa: D400 65 | 66 | exception: Exception 67 | 68 | @property 69 | def exception_class(self): 70 | """Class of the exception.""" 71 | return self.exception.__class__.__name__ 72 | 73 | @property 74 | def message(self): 75 | """Exception message.""" 76 | return str(self.exception) 77 | 78 | 79 | class TracebackAdvice(Documented): # pragma: no cover 80 | """ 81 | 💡 To see Python traceback, use: 82 | 83 | `j --log-level info {self.args}` 84 | """ # noqa: D400 85 | 86 | @property 87 | def args(self): 88 | """Format current CLI args.""" 89 | return ' '.join(funcy.rest(sys.argv)) 90 | -------------------------------------------------------------------------------- /jeeves_shell/import_by_path.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | 5 | def import_by_path(path: Path): 6 | """Import Jeeves by path.""" 7 | string_path = str(path) 8 | sys.path.insert(0, string_path) 9 | 10 | import jeeves # noqa: WPS433 11 | 12 | sys.path.remove(string_path) 13 | 14 | return jeeves 15 | -------------------------------------------------------------------------------- /jeeves_shell/jeeves.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from typer import Typer 4 | 5 | 6 | class LogLevel(str, Enum): 7 | """Logging level for the application.""" 8 | 9 | DEBUG = 'debug' 10 | INFO = 'info' # noqa: WPS110 11 | ERROR = 'error' 12 | 13 | 14 | class Jeeves(Typer): 15 | """Support Jeeves-specific options.""" 16 | 17 | log_level: LogLevel = LogLevel.ERROR 18 | -------------------------------------------------------------------------------- /macros.py: -------------------------------------------------------------------------------- 1 | """MkDocs macros for the documentation site.""" 2 | import functools 3 | import os 4 | import shutil 5 | import tempfile 6 | import textwrap 7 | from pathlib import Path 8 | from typing import Dict, List, Optional 9 | 10 | from mkdocs_macros.plugin import MacrosPlugin 11 | from sh import ErrorReturnCode, bash 12 | from sh import j as jeeves 13 | from sh import python 14 | 15 | STDERR_TEMPLATE = """ 16 | !!! danger "Error" 17 | ``` 18 | {stderr} 19 | ``` 20 | """ 21 | 22 | 23 | PYTHON_TEMPLATE = """ 24 | ```python title="{path}" 25 | {code} 26 | ``` 27 | 28 | {annotations} 29 | 30 | ⇒ 31 | ```python title="{cmd} {path}" 32 | {stdout} 33 | ``` 34 | 35 | {stderr} 36 | """ 37 | 38 | 39 | JEEVES_TEMPLATE = """ 40 | ``` title="↦ {cmd}" 41 | {stdout} 42 | ``` 43 | 44 | {stderr} 45 | """ 46 | 47 | 48 | TERMINAL_TEMPLATE = """ 49 | ``` title="↦ {title}" 50 | {output} 51 | ``` 52 | """ 53 | 54 | CODE_TEMPLATE = """ 55 | ```{language} title="{title}" 56 | {code} 57 | ``` 58 | 59 | {annotations} 60 | """ 61 | 62 | 63 | def format_annotations(annotations: List[str]) -> str: 64 | """Format annotations for mkdocs-material to accept them.""" 65 | enumerated_annotations = enumerate(annotations, start=1) 66 | 67 | return '\n\n'.join( 68 | f'{number}. {annotation}' 69 | for number, annotation in enumerated_annotations 70 | ) 71 | 72 | 73 | 74 | def code( 75 | path: str, 76 | docs_dir: Path, 77 | language: Optional[str] = None, 78 | title: Optional[str] = None, 79 | annotations: Optional[List[str]] = None, 80 | indent: Optional[int] = None, 81 | ): 82 | code_content = (docs_dir / path).read_text() 83 | 84 | response = CODE_TEMPLATE.format( 85 | language=language, 86 | code=code_content, 87 | title=title or path, 88 | annotations=format_annotations(annotations or []), 89 | ) 90 | 91 | if indent: 92 | return textwrap.indent( 93 | response, 94 | ' ' * indent, 95 | ) 96 | 97 | return response 98 | 99 | 100 | def run_python_script( 101 | path: str, 102 | docs_dir: Path, 103 | annotations: Optional[List[str]] = None, 104 | args: Optional[List[str]] = None, 105 | ): 106 | if annotations is None: 107 | annotations = [] 108 | 109 | if args is None: 110 | args = [] 111 | 112 | code_path = docs_dir / path 113 | code = code_path.read_text() 114 | 115 | _, stdout, stderr = python.run(*args, code_path, retcode=None) 116 | 117 | cmd = 'python' 118 | if args: 119 | formatted_args = ' '.join(args) 120 | cmd = f'{cmd} {formatted_args}' 121 | 122 | return PYTHON_TEMPLATE.format( 123 | path=path, 124 | code=code, 125 | stdout=stdout, 126 | stderr=stderr, 127 | annotations=format_annotations(annotations), 128 | cmd=cmd, 129 | ) 130 | 131 | 132 | def formatted_stderr(stderr: Optional[str]) -> str: 133 | if not stderr: 134 | return '' 135 | 136 | return STDERR_TEMPLATE.format(stderr=stderr) 137 | 138 | 139 | def j( 140 | path: Optional[str], 141 | docs_dir: Path, 142 | annotations: Optional[List[str]] = None, 143 | args: Optional[List[str]] = None, 144 | environment: Optional[Dict[str, str]] = None, 145 | ): 146 | environment = environment or {} 147 | 148 | environment = { 149 | **os.environ, 150 | **environment, 151 | 'TERM': 'dumb', 152 | } 153 | 154 | if annotations is None: 155 | annotations = [] 156 | 157 | if args is None: 158 | args = [] 159 | 160 | with tempfile.TemporaryDirectory() as raw_directory: 161 | directory = Path(raw_directory) 162 | 163 | if path is not None: 164 | code_path = docs_dir / path 165 | 166 | if code_path.is_file(): 167 | (directory / 'jeeves.py').write_text(code_path.read_text()) 168 | 169 | else: 170 | shutil.copytree( 171 | code_path, 172 | directory / 'jeeves', 173 | ) 174 | 175 | try: 176 | response = jeeves( 177 | *args, 178 | _cwd=directory, 179 | _env=environment, 180 | _tty_out=False, 181 | ) 182 | except ErrorReturnCode as err: 183 | stdout = err.stdout.decode() or '(stdout is empty)' 184 | stderr = textwrap.indent( 185 | err.stderr.decode(), 186 | prefix=' ', 187 | ) 188 | else: 189 | stdout = response 190 | stderr = None 191 | 192 | cmd = 'j' 193 | if args: 194 | formatted_args = ' '.join(args) 195 | cmd = f'{cmd} {formatted_args}' 196 | 197 | return JEEVES_TEMPLATE.format( 198 | path=path, 199 | code=code, 200 | stdout=stdout, 201 | stderr=formatted_stderr(stderr), 202 | annotations=format_annotations(annotations), 203 | cmd=cmd, 204 | ) 205 | 206 | 207 | def terminal( 208 | command: str, 209 | title: Optional[str] = None, 210 | environment: Optional[Dict[str, str]] = None, 211 | cwd: Optional[str] = None, 212 | ): 213 | execute = bash['-c'].with_env( 214 | **(environment or {}), 215 | ) 216 | 217 | if cwd: 218 | execute = execute.with_cwd(cwd) 219 | 220 | output = execute(command) 221 | 222 | return TERMINAL_TEMPLATE.format( 223 | output=output, 224 | title=title or command, 225 | ) 226 | 227 | 228 | def define_env(env: MacrosPlugin): 229 | """Hook function.""" 230 | env.macro( 231 | functools.partial( 232 | run_python_script, 233 | docs_dir=Path(env.conf['docs_dir']), 234 | ), 235 | name='run_python_script', 236 | ) 237 | 238 | env.macro( 239 | functools.partial( 240 | j, 241 | docs_dir=Path(env.conf['docs_dir']), 242 | ), 243 | name='j', 244 | ) 245 | 246 | env.macro( 247 | functools.partial( 248 | code, 249 | docs_dir=Path(env.conf['docs_dir']), 250 | ), 251 | name='code', 252 | ) 253 | 254 | env.macro(terminal) 255 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: jeeves 2 | repo_url: https://github.com/jeeves-sh/jeeves-shell 3 | repo_name: jeeves-sh/jeeves-shell 4 | edit_uri: edit/master/docs/ 5 | copyright: Copyright © 2023 jeeves.sh 6 | 7 | markdown_extensions: 8 | - pymdownx.tabbed: 9 | alternate_style: true 10 | - attr_list 11 | - md_in_html 12 | - admonition 13 | - codehilite 14 | - attr_list 15 | - pymdownx.inlinehilite 16 | - pymdownx.superfences 17 | - pymdownx.emoji: 18 | emoji_index: !!python/name:materialx.emoji.twemoji 19 | emoji_generator: !!python/name:materialx.emoji.to_svg 20 | 21 | plugins: 22 | - search 23 | - mkdocstrings 24 | - awesome-pages 25 | - iolanta 26 | - macros: 27 | modules: 28 | - macros 29 | 30 | theme: 31 | name: material 32 | icon: 33 | logo: material/bow-tie 34 | features: 35 | - content.action.edit 36 | - header.autohide 37 | - navigation.indexes 38 | - content.code.annotate 39 | - navigation.sections 40 | 41 | #extra_javascript: 42 | # - assets/termynal/termynal.js 43 | 44 | extra_css: 45 | - assets/termynal/termynal.css 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | 6 | [tool.poetry] 7 | name = "jeeves-shell" 8 | description = "Pythonic replacement for GNU Make" 9 | version = "2.3.4" 10 | license = "MIT" 11 | 12 | authors = [] 13 | 14 | readme = "README.md" 15 | 16 | repository = "https://github.com/jeeves-sh/jeeves-shell" 17 | 18 | keywords = [] 19 | 20 | classifiers = [ 21 | "Development Status :: 3 - Alpha", 22 | "Intended Audience :: Developers", 23 | "Operating System :: OS Independent", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | ] 26 | 27 | 28 | [tool.poetry.scripts] 29 | j = "jeeves_shell.cli:app" 30 | 31 | 32 | [tool.poetry.dependencies] 33 | python = ">=3.10,<4.0" 34 | typer = ">=0.12.4" 35 | documented = ">=0.1.4" 36 | funcy = ">=2.0" 37 | rich = ">=13.3.5" 38 | sh = {version = ">=2.0.4", optional = true} 39 | 40 | [tool.poetry.extras] 41 | all = ["sh"] 42 | 43 | [tool.poetry.group.dev.dependencies] 44 | m2r2 = "^0.2" 45 | mkdocstrings = "^0.19.1" 46 | 47 | markupsafe = "<2.1" # Otherwise, `jinja2` will fail 48 | importlib-metadata = "<5.0" 49 | mkdocs-macros-plugin = "^0.7.0" 50 | dominate = "^2.7.0" 51 | iolanta-tables = "^0.1.7" 52 | mkdocs-awesome-pages-plugin = "^2.9.1" 53 | mkdocs-material = "^9.1.13" 54 | mkdocs-iolanta = "^0.1.5" 55 | sh = "^2.0.4" 56 | iolanta-roadmap = "^0.1.0" 57 | urllib3 = "<2.0.0" 58 | 59 | [tool.flakeheaven.exceptions."jeeves_shell/jeeves.py"] 60 | wemake-python-styleguide = [ 61 | "-WPS115", 62 | "-WPS600", 63 | ] 64 | 65 | [tool.flakeheaven.exceptions."jeeves_shell/discover.py"] 66 | wemake-python-styleguide = [ 67 | "-WPS404", 68 | 69 | # Found an iterable unpacking to list 70 | "-WPS359", 71 | 72 | # Found single element destructuring 73 | "-WPS460", 74 | ] 75 | 76 | 77 | 78 | [tool.flakeheaven.exceptions."jeeves_shell/entry_points.py"] 79 | wemake-python-styleguide = [ 80 | "-WPS433", 81 | "-WPS440", 82 | ] 83 | pyflakes = [ 84 | # %s imported but unused 85 | "-F401", 86 | ] 87 | 88 | 89 | [tool.flakeheaven.exceptions."jeeves_shell/__init__.py"] 90 | pyflakes = [ 91 | # %s imported but unused 92 | "-F401", 93 | ] 94 | 95 | [tool.flakeheaven.exceptions."docs/plugins/how-to.md"] 96 | flake8-eradicate = [ 97 | # Found commented out code 98 | "-E800" 99 | ] 100 | -------------------------------------------------------------------------------- /test_samples/empty.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeeves-sh/jeeves-shell/c715b4c5eef0118f917f8ecf5c8555036e06efc9/test_samples/empty.py -------------------------------------------------------------------------------- /test_samples/import_error.py: -------------------------------------------------------------------------------- 1 | import foo 2 | 3 | 4 | def boo(): 5 | """Boo.""" 6 | -------------------------------------------------------------------------------- /test_samples/multiple.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | return 'foo' 3 | 4 | 5 | def boo(): 6 | return 'boo' 7 | -------------------------------------------------------------------------------- /test_samples/package/__init__.py: -------------------------------------------------------------------------------- 1 | from jeeves.linter import lint 2 | 3 | 4 | def test(): 5 | """Test.""" 6 | -------------------------------------------------------------------------------- /test_samples/package/linter.py: -------------------------------------------------------------------------------- 1 | def lint(): 2 | """Lint.""" 3 | -------------------------------------------------------------------------------- /test_samples/single.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | return 'foo' 3 | -------------------------------------------------------------------------------- /test_samples/sub_app.py: -------------------------------------------------------------------------------- 1 | from typer import Typer 2 | 3 | build = Typer() 4 | 5 | 6 | @build.command(name='all') 7 | def _build_all(): 8 | """Build all.""" 9 | 10 | 11 | @build.command(name='python') 12 | def _python(): 13 | """Build Python.""" 14 | 15 | 16 | @build.command(name='rust') 17 | def _rust(): 18 | """Build Rust.""" 19 | 20 | 21 | def lint(): 22 | """Lint.""" 23 | -------------------------------------------------------------------------------- /test_samples/syntax_error.txt: -------------------------------------------------------------------------------- 1 | def foo(): 2 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | 6 | 7 | @contextmanager 8 | def environment_from_jeeves_file(path: Path): 9 | with tempfile.TemporaryDirectory() as raw_directory: 10 | directory = Path(raw_directory) 11 | (directory / 'jeeves.py').write_text( 12 | path.read_text(), 13 | ) 14 | yield directory 15 | 16 | 17 | @contextmanager 18 | def environment_from_jeeves_package(path: Path): 19 | with tempfile.TemporaryDirectory() as raw_directory: 20 | directory = Path(raw_directory) 21 | shutil.copytree(path, directory / 'jeeves') 22 | yield directory 23 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import sys 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def wipe_jeeves_module_from_cache(): 11 | try: 12 | return sys.modules.__delitem__('jeeves') # noqa: WPS609 13 | except KeyError: 14 | return False 15 | 16 | 17 | @pytest.fixture(scope='session') 18 | def jeeves_files() -> Path: 19 | return Path(__file__).parent.parent / 'test_samples' 20 | 21 | 22 | @pytest.fixture() 23 | def random_string() -> str: 24 | return ''.join( 25 | random.sample( 26 | string.ascii_lowercase, 27 | 10, 28 | ), 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from base import environment_from_jeeves_file 5 | 6 | from jeeves_shell import Jeeves 7 | from jeeves_shell.cli import execute_app 8 | from jeeves_shell.discover import construct_app 9 | from jeeves_shell.errors import NoCommandsFound 10 | 11 | 12 | def test_execute_empty_app(): 13 | app = Jeeves() 14 | 15 | with pytest.raises(NoCommandsFound): 16 | execute_app(app) 17 | 18 | 19 | def test_execute_non_empty_app(jeeves_files: Path): 20 | with environment_from_jeeves_file(jeeves_files / 'empty.py') as directory: 21 | app = construct_app(directory) 22 | 23 | with pytest.raises(SystemExit): 24 | execute_app(app) 25 | -------------------------------------------------------------------------------- /tests/test_construct_root_app.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | import pytest 4 | from jeeves_shell import Jeeves 5 | from jeeves_shell.discover import construct_root_app 6 | from jeeves_shell.errors import PluginConflict 7 | 8 | 9 | def test_fallback(): 10 | assert isinstance(construct_root_app(defaultdict(list)), Jeeves) 11 | 12 | 13 | def test_conflict(): 14 | with pytest.raises(PluginConflict) as error_info: 15 | construct_root_app(plugins_by_mount_point=defaultdict( 16 | list, 17 | __root__=[Jeeves(), Jeeves()], 18 | )) 19 | 20 | assert 'Plugins' in str(error_info.value) # noqa: WPS441 21 | -------------------------------------------------------------------------------- /tests/test_jeeves_file.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | from pathlib import Path 4 | 5 | import funcy 6 | import pytest 7 | from base import environment_from_jeeves_file, environment_from_jeeves_package 8 | from typer import Typer 9 | 10 | from jeeves_shell.discover import retrieve_commands_from_jeeves_file 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def reload_jeeves(): 15 | yield 16 | sys.modules.pop('jeeves', None) 17 | 18 | 19 | def test_missing_directory(jeeves_files: Path, random_string: str): 20 | assert not list( 21 | retrieve_commands_from_jeeves_file( 22 | Path(f'/tmp/random_{random_string}'), # noqa: S108 23 | ), 24 | ) 25 | 26 | 27 | def test_missing_file(jeeves_files: Path, random_string: str): 28 | with environment_from_jeeves_file(jeeves_files / 'empty.py') as directory: 29 | names_and_commands = list( 30 | retrieve_commands_from_jeeves_file(directory=directory), 31 | ) 32 | assert not list(names_and_commands) 33 | 34 | 35 | def test_empty(jeeves_files: Path): 36 | with environment_from_jeeves_file(jeeves_files / 'empty.py') as directory: 37 | names_and_commands = list(retrieve_commands_from_jeeves_file(directory)) 38 | assert len(names_and_commands) == 0, names_and_commands 39 | 40 | 41 | def test_single(jeeves_files: Path): 42 | with environment_from_jeeves_file(jeeves_files / 'single.py') as directory: 43 | name, _command = funcy.first( 44 | retrieve_commands_from_jeeves_file(directory), 45 | ) 46 | 47 | assert name == 'foo' 48 | 49 | 50 | def test_sub_app(jeeves_files: Path): 51 | with environment_from_jeeves_file(jeeves_files / 'sub_app.py') as directory: 52 | command_by_name = dict(retrieve_commands_from_jeeves_file(directory)) 53 | 54 | assert set(command_by_name.keys()) == {'build', 'lint'} 55 | 56 | sub_command: Typer = command_by_name['build'] 57 | assert { 58 | command.name for command in sub_command.registered_commands 59 | } == {'all', 'python', 'rust'} 60 | 61 | 62 | def test_multiple(jeeves_files: Path): 63 | with environment_from_jeeves_file( 64 | jeeves_files / 'multiple.py', 65 | ) as directory: 66 | command_names = list( 67 | map( 68 | funcy.first, 69 | retrieve_commands_from_jeeves_file(directory), 70 | ), 71 | ) 72 | 73 | assert command_names == ['foo', 'boo'] 74 | 75 | 76 | def test_missing(jeeves_files: Path): 77 | with tempfile.TemporaryDirectory() as raw_directory: 78 | assert not list( 79 | retrieve_commands_from_jeeves_file( 80 | # This directory does not exist ⬎ 81 | Path(raw_directory) / 'foo', 82 | ), 83 | ) 84 | 85 | 86 | def test_syntax_error(jeeves_files: Path): 87 | with environment_from_jeeves_file( 88 | jeeves_files / 'syntax_error.txt', 89 | ) as directory: 90 | with pytest.raises(SyntaxError): 91 | list(retrieve_commands_from_jeeves_file(directory)) 92 | 93 | 94 | def test_import_error(jeeves_files: Path): 95 | with environment_from_jeeves_file( 96 | jeeves_files / 'import_error.py', 97 | ) as directory: 98 | with pytest.raises(ImportError): 99 | list(retrieve_commands_from_jeeves_file(directory)) 100 | 101 | 102 | def test_package(jeeves_files: Path): 103 | with environment_from_jeeves_package( 104 | jeeves_files / 'package', 105 | ) as directory: 106 | command_names = set( 107 | map( 108 | funcy.first, 109 | retrieve_commands_from_jeeves_file(directory), 110 | ), 111 | ) 112 | 113 | assert command_names == {'lint', 'test'} 114 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | from jeeves_shell.discover import list_installed_plugins 2 | 3 | 4 | def test_plugins(): 5 | assert isinstance(list_installed_plugins(), dict) 6 | --------------------------------------------------------------------------------