├── .copier-answers.yml ├── .envrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-bug.md │ ├── 2-feature.md │ ├── 3-docs.md │ ├── 4-change.md │ └── config.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── config ├── coverage.ini ├── git-changelog.toml ├── mypy.ini ├── pytest.ini ├── ruff.toml └── vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── demo.bash ├── demo.script ├── demo.svg ├── docs ├── .overrides │ ├── main.html │ └── partials │ │ ├── comments.html │ │ └── path-item.html ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── credits.md ├── css │ ├── material.css │ └── mkdocstrings.css ├── index.md ├── js │ └── feedback.js ├── license.md ├── reference │ └── api.md ├── todo.md └── usage │ ├── index.md │ ├── plugins.md │ ├── syntax.md │ └── tags.md ├── duties.py ├── logo.png ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── gen_credits.py ├── get_version.py ├── make └── make.py ├── src └── shellman │ ├── __init__.py │ ├── __main__.py │ ├── _internal │ ├── __init__.py │ ├── cli.py │ ├── context.py │ ├── debug.py │ ├── reader.py │ ├── tags.py │ └── templates │ │ ├── __init__.py │ │ ├── data │ │ ├── helptext │ │ ├── helptext_function │ │ ├── manpage.groff │ │ ├── manpage.md │ │ ├── manpage_function.groff │ │ ├── manpage_function.md │ │ ├── usagetext │ │ ├── wikipage.md │ │ ├── wikipage_function.md │ │ └── wikipage_toc.md │ │ └── filters.py │ ├── cli.py │ ├── context.py │ ├── py.typed │ ├── reader.py │ ├── tags.py │ └── templates │ ├── __init__.py │ └── filters.py └── tests ├── __init__.py ├── conftest.py ├── fakescripts └── simple.sh ├── test_api.py ├── test_cli.py ├── test_context.py ├── test_reader.py └── test_tags.py /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier. 2 | _commit: 1.8.2 3 | _src_path: gh:pawamoy/copier-uv 4 | author_email: dev@pawamoy.fr 5 | author_fullname: Timothée Mazzucotelli 6 | author_username: pawamoy 7 | copyright_date: '2020' 8 | copyright_holder: Timothée Mazzucotelli 9 | copyright_holder_email: dev@pawamoy.fr 10 | copyright_license: ISC 11 | insiders: false 12 | project_description: Write documentation in comments and render it with templates. 13 | project_name: shellman 14 | python_package_command_line_name: shellman 15 | python_package_distribution_name: shellman 16 | python_package_import_name: shellman 17 | repository_name: shellman 18 | repository_namespace: pawamoy 19 | repository_provider: github.com 20 | 21 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | PATH_add scripts 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pawamoy 2 | polar: pawamoy 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: "bug: " 5 | labels: unconfirmed 6 | assignees: [pawamoy] 7 | --- 8 | 9 | ### Description of the bug 10 | 11 | 12 | ### To Reproduce 13 | 28 | 29 | ``` 30 | WRITE MRE / INSTRUCTIONS HERE 31 | ``` 32 | 33 | ### Full traceback 34 | 36 | 37 |
Full traceback 38 | 39 | ```python 40 | PASTE TRACEBACK HERE 41 | ``` 42 | 43 |
44 | 45 | ### Expected behavior 46 | 47 | 48 | ### Environment information 49 | 51 | 52 | ```bash 53 | shellman --debug-info # | xclip -selection clipboard 54 | ``` 55 | 56 | PASTE MARKDOWN OUTPUT HERE 57 | 58 | ### Additional context 59 | 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: "feature: " 5 | labels: feature 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation update 3 | about: Point at unclear, missing or outdated documentation. 4 | title: "docs: " 5 | labels: docs 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is something unclear, missing or outdated in our documentation? 10 | 11 | 12 | ### Relevant code snippets 13 | 14 | 15 | ### Link to the relevant documentation section 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-change.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Suggest any other kind of change for this project. 4 | title: "change: " 5 | assignees: pawamoy 6 | --- 7 | 8 | ### Is your change request related to a problem? Please describe. 9 | 10 | 11 | ### Describe the solution you'd like 12 | 13 | 14 | ### Describe alternatives you've considered 15 | 16 | 17 | ### Additional context 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question / I need help 4 | url: https://github.com/pawamoy/shellman/discussions/new?category=q-a 5 | about: Ask and answer questions in the Discussions tab. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: en_US.utf-8 15 | LC_ALL: en_US.utf-8 16 | PYTHONIOENCODING: UTF-8 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | quality: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | fetch-tags: true 31 | 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.12" 36 | 37 | - name: Setup uv 38 | uses: astral-sh/setup-uv@v5 39 | with: 40 | enable-cache: true 41 | cache-dependency-glob: pyproject.toml 42 | 43 | - name: Install dependencies 44 | run: make setup 45 | 46 | - name: Check if the documentation builds correctly 47 | run: make check-docs 48 | 49 | - name: Check the code quality 50 | run: make check-quality 51 | 52 | - name: Check if the code is correctly typed 53 | run: make check-types 54 | 55 | - name: Check for breaking changes in the API 56 | run: make check-api 57 | 58 | - name: Store objects inventory for tests 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: objects.inv 62 | path: site/objects.inv 63 | 64 | tests: 65 | 66 | needs: 67 | - quality 68 | strategy: 69 | matrix: 70 | os: 71 | - ubuntu-latest 72 | - macos-latest 73 | - windows-latest 74 | python-version: 75 | - "3.9" 76 | - "3.10" 77 | - "3.11" 78 | - "3.12" 79 | - "3.13" 80 | - "3.14" 81 | resolution: 82 | - highest 83 | - lowest-direct 84 | exclude: 85 | - os: macos-latest 86 | resolution: lowest-direct 87 | - os: windows-latest 88 | resolution: lowest-direct 89 | runs-on: ${{ matrix.os }} 90 | continue-on-error: ${{ matrix.python-version == '3.14' }} 91 | 92 | steps: 93 | - name: Checkout 94 | uses: actions/checkout@v4 95 | with: 96 | fetch-depth: 0 97 | fetch-tags: true 98 | 99 | - name: Setup Python 100 | uses: actions/setup-python@v5 101 | with: 102 | python-version: ${{ matrix.python-version }} 103 | allow-prereleases: true 104 | 105 | - name: Setup uv 106 | uses: astral-sh/setup-uv@v5 107 | with: 108 | enable-cache: true 109 | cache-dependency-glob: pyproject.toml 110 | cache-suffix: ${{ matrix.resolution }} 111 | 112 | - name: Install dependencies 113 | env: 114 | UV_RESOLUTION: ${{ matrix.resolution }} 115 | run: make setup 116 | 117 | - name: Download objects inventory 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: objects.inv 121 | path: site/ 122 | 123 | - name: Run the test suite 124 | run: make test 125 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.ref, 'refs/tags/') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | fetch-tags: true 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Setup uv 22 | uses: astral-sh/setup-uv@v5 23 | - name: Prepare release notes 24 | run: uv tool run git-changelog --release-notes > release-notes.md 25 | - name: Create release 26 | uses: softprops/action-gh-release@v2 27 | with: 28 | body_path: release-notes.md 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # python 6 | *.egg-info/ 7 | *.py[cod] 8 | .venv/ 9 | .venvs/ 10 | /build/ 11 | /dist/ 12 | 13 | # tools 14 | .coverage* 15 | /.pdm-build/ 16 | /htmlcov/ 17 | /site/ 18 | uv.lock 19 | 20 | # cache 21 | .cache/ 22 | .pytest_cache/ 23 | .mypy_cache/ 24 | .ruff_cache/ 25 | __pycache__/ 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at dev@pawamoy.fr. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 76 | 77 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 81 | [Mozilla CoC]: https://github.com/mozilla/diversity 82 | [FAQ]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 4 | 5 | ## Environment setup 6 | 7 | Nothing easier! 8 | 9 | Fork and clone the repository, then: 10 | 11 | ```bash 12 | cd shellman 13 | make setup 14 | ``` 15 | 16 | > NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. 17 | > 18 | > You can install it with: 19 | > 20 | > ```bash 21 | > curl -LsSf https://astral.sh/uv/install.sh | sh 22 | > ``` 23 | > 24 | > Now you can try running `make setup` again, or simply `uv sync`. 25 | 26 | You now have the dependencies installed. 27 | 28 | You can run the application with `make run shellman [ARGS...]`. 29 | 30 | Run `make help` to see all the available actions! 31 | 32 | ## Tasks 33 | 34 | The entry-point to run commands and tasks is the `make` Python script, located in the `scripts` directory. Try running `make` to show the available commands and tasks. The *commands* do not need the Python dependencies to be installed, 35 | while the *tasks* do. The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). 36 | 37 | If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. 38 | 39 | ## Development 40 | 41 | As usual: 42 | 43 | 1. create a new branch: `git switch -c feature-or-bugfix-name` 44 | 1. edit the code and/or the documentation 45 | 46 | **Before committing:** 47 | 48 | 1. run `make format` to auto-format the code 49 | 1. run `make check` to check everything (fix any warning) 50 | 1. run `make test` to run the tests (fix any issue) 51 | 1. if you updated the documentation or the project dependencies: 52 | 1. run `make docs` 53 | 1. go to http://localhost:8000 and check that everything looks good 54 | 1. follow our [commit message convention](#commit-message-convention) 55 | 56 | If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. 57 | 58 | Don't bother updating the changelog, we will take care of this. 59 | 60 | ## Commit message convention 61 | 62 | Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): 63 | 64 | ``` 65 | [(scope)]: Subject 66 | 67 | [Body] 68 | ``` 69 | 70 | **Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. 71 | 72 | Scope and body are optional. Type can be: 73 | 74 | - `build`: About packaging, building wheels, etc. 75 | - `chore`: About packaging or repo/files management. 76 | - `ci`: About Continuous Integration. 77 | - `deps`: Dependencies update. 78 | - `docs`: About documentation. 79 | - `feat`: New feature. 80 | - `fix`: Bug fix. 81 | - `perf`: About performance. 82 | - `refactor`: Changes that are not features or bug fixes. 83 | - `style`: A change in code style/format. 84 | - `tests`: About tests. 85 | 86 | If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown: 87 | 88 | ``` 89 | Body. 90 | 91 | Issue #10: https://github.com/namespace/project/issues/10 92 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 93 | ``` 94 | 95 | These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number). 96 | 97 | We do not enforce a line length on commit messages summary and body, but please avoid very long summaries, and very long lines in the body, unless they are part of code blocks that must not be wrapped. 98 | 99 | ## Pull requests guidelines 100 | 101 | Link to any related issue in the Pull Request message. 102 | 103 | During the review, we recommend using fixups: 104 | 105 | ```bash 106 | # SHA is the SHA of the commit you want to fix 107 | git commit --fixup=SHA 108 | ``` 109 | 110 | Once all the changes are approved, you can squash your commits: 111 | 112 | ```bash 113 | git rebase -i --autosquash main 114 | ``` 115 | 116 | And force-push: 117 | 118 | ```bash 119 | git push -f 120 | ``` 121 | 122 | If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Timothée Mazzucotelli 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # If you have `direnv` loaded in your shell, and allow it in the repository, 2 | # the `make` command will point at the `scripts/make` shell script. 3 | # This Makefile is just here to allow auto-completion in the terminal. 4 | 5 | actions = \ 6 | allrun \ 7 | changelog \ 8 | check \ 9 | check-api \ 10 | check-docs \ 11 | check-quality \ 12 | check-types \ 13 | clean \ 14 | coverage \ 15 | docs \ 16 | docs-deploy \ 17 | format \ 18 | help \ 19 | multirun \ 20 | release \ 21 | run \ 22 | setup \ 23 | test \ 24 | vscode 25 | 26 | .PHONY: $(actions) 27 | $(actions): 28 | @python scripts/make "$@" 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Shell Script Documentation

6 | 7 |

Write documentation in comments and render it with templates.

8 | 9 |

10 | 11 | ci 12 | 13 | 14 | documentation 15 | 16 | 17 | pypi 18 | 19 | 20 | gitpod 21 | 22 | 23 | gitter 24 | 25 |

26 | 27 | `shellman` can generate man pages, wiki pages and help text 28 | using documentation written in shell scripts comments. 29 | 30 | For example: 31 | 32 | ```bash 33 | #!/bin/bash 34 | 35 | ## \brief Just a demo 36 | ## \desc This script actually does nothing. 37 | 38 | main() { 39 | case "$1" in 40 | ## \option -h, --help 41 | ## Print this help and exit. 42 | -h|--help) shellman "$0"; exit 0 ;; 43 | esac 44 | } 45 | 46 | ## \usage demo [-h] 47 | main "$@" 48 | ``` 49 | 50 | Output when calling ``./demo -h``: 51 | 52 | ``` 53 | Usage: demo [-h] 54 | 55 | This script actually does nothing. 56 | 57 | Options: 58 | -h, --help Print this help and exit. 59 | ``` 60 | 61 | You can see more examples in the documentation: https://pawamoy.github.io/shellman/. 62 | 63 |

Demo

64 |

65 | 66 | In the demo above we saw the three builtin templates: 67 | helptext, manpage and wikipage. 68 | 69 | You can use your own templates 70 | by specifying them with the ``--template path:my/template`` syntax. 71 | 72 | You can also write a plugin, see the docs: https://pawamoy.github.io/shellman/plugins. 73 | 74 | ## Installation 75 | 76 | ```bash 77 | pip install shellman 78 | ``` 79 | 80 | With [`uv`](https://docs.astral.sh/uv/): 81 | 82 | ```bash 83 | uv tool install shellman 84 | ``` 85 | 86 | ## Some projects using shellman 87 | 88 | - [shellm](https://github.com/shellm-org) — 89 | A collection of scripts and libraries 90 | built on a [core inclusion-system](https://github.com/shellm-org/core), 91 | all installable with [basher](https://github.com/basherpm/basher). 92 | Here are a few examples: 93 | - [daemon](https://github.com/shellm-org/daemon) — 94 | A library that facilitates the writing of daemonized scripts that consume 95 | files in a watched directory. 96 | - [debug](https://github.com/shellm-org/debug) — 97 | A simple script that sets the verbose/dry-run/debug 98 | Bash flags before running another script. 99 | - [format](https://github.com/shellm-org/format) — 100 | Format your output with style and color. 101 | - [home](https://github.com/shellm-org/home) — 102 | A home for your shell scripts! 103 | - [loop](https://github.com/shellm-org/loop) — 104 | Control the flow of your loops (pause/resume/etc.). 105 | -------------------------------------------------------------------------------- /config/coverage.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | parallel = true 4 | source = 5 | src/ 6 | tests/ 7 | 8 | [coverage:paths] 9 | equivalent = 10 | src/ 11 | .venv/lib/*/site-packages/ 12 | .venvs/*/lib/*/site-packages/ 13 | 14 | [coverage:report] 15 | precision = 2 16 | omit = 17 | src/*/__init__.py 18 | src/*/__main__.py 19 | src/*/templates/* 20 | tests/__init__.py 21 | exclude_lines = 22 | pragma: no cover 23 | if TYPE_CHECKING 24 | 25 | [coverage:json] 26 | output = htmlcov/coverage.json 27 | -------------------------------------------------------------------------------- /config/git-changelog.toml: -------------------------------------------------------------------------------- 1 | bump = "auto" 2 | convention = "angular" 3 | in-place = true 4 | output = "CHANGELOG.md" 5 | parse-refs = false 6 | parse-trailers = true 7 | sections = ["build", "deps", "feat", "fix", "refactor"] 8 | template = "keepachangelog" 9 | versioning = "pep440" 10 | -------------------------------------------------------------------------------- /config/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = tests/fixtures/ 4 | warn_unused_ignores = false 5 | show_error_codes = true 6 | -------------------------------------------------------------------------------- /config/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = 3 | test_*.py 4 | addopts = 5 | --cov 6 | --cov-config config/coverage.ini 7 | testpaths = 8 | tests 9 | 10 | # action:message_regex:warning_class:module_regex:line 11 | filterwarnings = 12 | error 13 | # TODO: remove once pytest-xdist 4 is released 14 | ignore:.*rsyncdir:DeprecationWarning:xdist 15 | -------------------------------------------------------------------------------- /config/ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [lint] 5 | exclude = [ 6 | "tests/fixtures/*.py", 7 | ] 8 | select = [ 9 | "A", "ANN", "ARG", 10 | "B", "BLE", 11 | "C", "C4", 12 | "COM", 13 | "D", "DTZ", 14 | "E", "ERA", "EXE", 15 | "F", "FBT", 16 | "G", 17 | "I", "ICN", "INP", "ISC", 18 | "N", 19 | "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", 20 | "Q", 21 | "RUF", "RSE", "RET", 22 | "S", "SIM", "SLF", 23 | "T", "T10", "T20", "TCH", "TID", "TRY", 24 | "UP", 25 | "W", 26 | "YTT", 27 | ] 28 | ignore = [ 29 | "A001", # Variable is shadowing a Python builtin 30 | "ANN101", # Missing type annotation for self 31 | "ANN102", # Missing type annotation for cls 32 | "ANN204", # Missing return type annotation for special method __str__ 33 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 34 | "ARG005", # Unused lambda argument 35 | "C901", # Too complex 36 | "D105", # Missing docstring in magic method 37 | "D417", # Missing argument description in the docstring 38 | "E501", # Line too long 39 | "ERA001", # Commented out code 40 | "G004", # Logging statement uses f-string 41 | "PLR0911", # Too many return statements 42 | "PLR0912", # Too many branches 43 | "PLR0913", # Too many arguments to function call 44 | "PLR0915", # Too many statements 45 | "SLF001", # Private member accessed 46 | "TRY003", # Avoid specifying long messages outside the exception class 47 | ] 48 | 49 | [lint.per-file-ignores] 50 | "src/**/cli.py" = [ 51 | "T201", # Print statement 52 | ] 53 | "src/*/debug.py" = [ 54 | "T201", # Print statement 55 | ] 56 | "!src/*/*.py" = [ 57 | "D100", # Missing docstring in public module 58 | ] 59 | "!src/**.py" = [ 60 | "D101", # Missing docstring in public class 61 | "D103", # Missing docstring in public function 62 | ] 63 | "scripts/*.py" = [ 64 | "INP001", # File is part of an implicit namespace package 65 | "T201", # Print statement 66 | ] 67 | "tests/**.py" = [ 68 | "ARG005", # Unused lambda argument 69 | "FBT001", # Boolean positional arg in function definition 70 | "PLR2004", # Magic value used in comparison 71 | "S101", # Use of assert detected 72 | ] 73 | 74 | [lint.flake8-quotes] 75 | docstring-quotes = "double" 76 | 77 | [lint.flake8-tidy-imports] 78 | ban-relative-imports = "all" 79 | 80 | [lint.isort] 81 | known-first-party = ["shellman"] 82 | 83 | [lint.pydocstyle] 84 | convention = "google" 85 | 86 | [format] 87 | exclude = [ 88 | "tests/fixtures/*.py", 89 | ] 90 | docstring-code-format = true 91 | docstring-code-line-length = 80 92 | -------------------------------------------------------------------------------- /config/vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "python (current file)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "args": "${command:pickArgs}" 12 | }, 13 | { 14 | "name": "run", 15 | "type": "debugpy", 16 | "request": "launch", 17 | "module": "shellman", 18 | "console": "integratedTerminal", 19 | "justMyCode": false, 20 | "args": "${command:pickArgs}" 21 | }, 22 | { 23 | "name": "docs", 24 | "type": "debugpy", 25 | "request": "launch", 26 | "module": "mkdocs", 27 | "justMyCode": false, 28 | "args": [ 29 | "serve", 30 | "-v" 31 | ] 32 | }, 33 | { 34 | "name": "test", 35 | "type": "debugpy", 36 | "request": "launch", 37 | "module": "pytest", 38 | "justMyCode": false, 39 | "args": [ 40 | "-c=config/pytest.ini", 41 | "-vvv", 42 | "--no-cov", 43 | "--dist=no", 44 | "tests", 45 | "-k=${input:tests_selection}" 46 | ] 47 | } 48 | ], 49 | "inputs": [ 50 | { 51 | "id": "tests_selection", 52 | "type": "promptString", 53 | "description": "Tests selection", 54 | "default": "" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /config/vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.venv*/**": true, 4 | "**/.venvs*/**": true, 5 | "**/venv*/**": true 6 | }, 7 | "mypy-type-checker.args": [ 8 | "--config-file=config/mypy.ini" 9 | ], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "--config-file=config/pytest.ini" 14 | ], 15 | "ruff.enable": true, 16 | "ruff.format.args": [ 17 | "--config=config/ruff.toml" 18 | ], 19 | "ruff.lint.args": [ 20 | "--config=config/ruff.toml" 21 | ], 22 | "yaml.schemas": { 23 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 24 | }, 25 | "yaml.customTags": [ 26 | "!ENV scalar", 27 | "!ENV sequence", 28 | "!relative scalar", 29 | "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", 30 | "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", 31 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" 32 | ] 33 | } -------------------------------------------------------------------------------- /config/vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "changelog", 6 | "type": "process", 7 | "command": "scripts/make", 8 | "args": ["changelog"] 9 | }, 10 | { 11 | "label": "check", 12 | "type": "process", 13 | "command": "scripts/make", 14 | "args": ["check"] 15 | }, 16 | { 17 | "label": "check-quality", 18 | "type": "process", 19 | "command": "scripts/make", 20 | "args": ["check-quality"] 21 | }, 22 | { 23 | "label": "check-types", 24 | "type": "process", 25 | "command": "scripts/make", 26 | "args": ["check-types"] 27 | }, 28 | { 29 | "label": "check-docs", 30 | "type": "process", 31 | "command": "scripts/make", 32 | "args": ["check-docs"] 33 | }, 34 | { 35 | "label": "check-api", 36 | "type": "process", 37 | "command": "scripts/make", 38 | "args": ["check-api"] 39 | }, 40 | { 41 | "label": "clean", 42 | "type": "process", 43 | "command": "scripts/make", 44 | "args": ["clean"] 45 | }, 46 | { 47 | "label": "docs", 48 | "type": "process", 49 | "command": "scripts/make", 50 | "args": ["docs"] 51 | }, 52 | { 53 | "label": "docs-deploy", 54 | "type": "process", 55 | "command": "scripts/make", 56 | "args": ["docs-deploy"] 57 | }, 58 | { 59 | "label": "format", 60 | "type": "process", 61 | "command": "scripts/make", 62 | "args": ["format"] 63 | }, 64 | { 65 | "label": "release", 66 | "type": "process", 67 | "command": "scripts/make", 68 | "args": ["release", "${input:version}"] 69 | }, 70 | { 71 | "label": "setup", 72 | "type": "process", 73 | "command": "scripts/make", 74 | "args": ["setup"] 75 | }, 76 | { 77 | "label": "test", 78 | "type": "process", 79 | "command": "scripts/make", 80 | "args": ["test", "coverage"], 81 | "group": "test" 82 | }, 83 | { 84 | "label": "vscode", 85 | "type": "process", 86 | "command": "scripts/make", 87 | "args": ["vscode"] 88 | } 89 | ], 90 | "inputs": [ 91 | { 92 | "id": "version", 93 | "type": "promptString", 94 | "description": "Version" 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /demo.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## \brief Just a demo. 4 | ## \desc This script actually does nothing. 5 | 6 | main() { 7 | case "$1" in 8 | ## \option -h, --help 9 | ## Print this help and exit. 10 | -h|--help) shellman "$0"; exit 0 ;; 11 | esac 12 | } 13 | 14 | ## \usage demo [-h] 15 | main "$@" 16 | -------------------------------------------------------------------------------- /demo.script: -------------------------------------------------------------------------------- 1 | # demo: charinterval=0 2 | workon py36 3 | export PROFILE_PROMPT=psss 4 | c() { clear; }; export -f c 5 | e() { exit; }; export -f e 6 | shellman() { command shellman "$@" -c shellman.credits=""; }; export -f shellman 7 | c # demo: charinterval=0.05 8 | 9 | termtosvg -g 80x20 demo.svg -t window_frame # demo: sleep 2 10 | cat demo.bash # demo: sleep 3 11 | c 12 | grep '##' demo.bash # demo: sleep 3 13 | c 14 | shellman demo.bash # demo: sleep 2 15 | c 16 | shellman demo.bash -t manpage # demo: sleep 2 17 | c 18 | man <(shellman demo.bash -t manpage) # demo: sleep 3 19 | qc 20 | shellman demo.bash -t wikipage # demo: sleep 3 21 | e # demo: sleep 1 22 | 23 | -------------------------------------------------------------------------------- /docs/.overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | 5 | Follow 6 | @pawamoy on 7 | 8 | 9 | {% include ".icons/fontawesome/brands/mastodon.svg" %} 10 | 11 | Fosstodon 12 | 13 | for updates 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /docs/.overrides/partials/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/.overrides/partials/path-item.html: -------------------------------------------------------------------------------- 1 | {# Fix breadcrumbs for when mkdocs-section-index is used. #} 2 | {# See https://github.com/squidfunk/mkdocs-material/issues/7614. #} 3 | 4 | 5 | {% macro render_content(nav_item) %} 6 | 7 | {{ nav_item.title }} 8 | 9 | {% endmacro %} 10 | 11 | 12 | {% macro render(nav_item, ref=nav_item) %} 13 | {% if nav_item.is_page %} 14 |
  • 15 | 16 | {{ render_content(ref) }} 17 | 18 |
  • 19 | {% elif nav_item.children %} 20 | {{ render(nav_item.children | first, ref) }} 21 | {% endif %} 22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | --8<-- "CHANGELOG.md" 6 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code of Conduct 3 | --- 4 | 5 | --8<-- "CODE_OF_CONDUCT.md" 6 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md" 6 | -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | hide: 4 | - toc 5 | --- 6 | 7 | ```python exec="yes" 8 | --8<-- "scripts/gen_credits.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/css/material.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: .05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: ' '; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } 28 | 29 | /* Tree-like output for backlinks. */ 30 | .doc-backlink-list { 31 | --tree-clr: var(--md-default-fg-color); 32 | --tree-font-size: 1rem; 33 | --tree-item-height: 1; 34 | --tree-offset: 1rem; 35 | --tree-thickness: 1px; 36 | --tree-style: solid; 37 | display: grid; 38 | list-style: none !important; 39 | } 40 | 41 | .doc-backlink-list li > span:first-child { 42 | text-indent: .3rem; 43 | } 44 | .doc-backlink-list li { 45 | padding-inline-start: var(--tree-offset); 46 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 47 | position: relative; 48 | margin-left: 0 !important; 49 | 50 | &:last-child { 51 | border-color: transparent; 52 | } 53 | &::before{ 54 | content: ''; 55 | position: absolute; 56 | top: calc(var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness)); 57 | left: calc(var(--tree-thickness) * -1); 58 | width: calc(var(--tree-offset) + var(--tree-thickness) * 2); 59 | height: calc(var(--tree-item-height) * var(--tree-font-size)); 60 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 61 | border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr); 62 | } 63 | &::after{ 64 | content: ''; 65 | position: absolute; 66 | border-radius: 50%; 67 | background-color: var(--tree-clr); 68 | top: calc(var(--tree-item-height) / 2 * 1rem); 69 | left: var(--tree-offset) ; 70 | translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - feedback 5 | --- 6 | 7 | --8<-- "README.md" 8 | -------------------------------------------------------------------------------- /docs/js/feedback.js: -------------------------------------------------------------------------------- 1 | const feedback = document.forms.feedback; 2 | feedback.hidden = false; 3 | 4 | feedback.addEventListener("submit", function(ev) { 5 | ev.preventDefault(); 6 | const commentElement = document.getElementById("feedback"); 7 | commentElement.style.display = "block"; 8 | feedback.firstElementChild.disabled = true; 9 | const data = ev.submitter.getAttribute("data-md-value"); 10 | const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); 11 | if (note) { 12 | note.hidden = false; 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | hide: 4 | - feedback 5 | --- 6 | 7 | # License 8 | 9 | ``` 10 | --8<-- "LICENSE" 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/reference/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API reference 3 | hide: 4 | - navigation 5 | --- 6 | 7 | # ::: shellman 8 | options: 9 | show_submodules: true 10 | -------------------------------------------------------------------------------- /docs/todo.md: -------------------------------------------------------------------------------- 1 | # To do 2 | 3 | **The work is not finished!** 4 | 5 | - General: 6 | 7 | - [ ] Write missing render functions in formatters 8 | - [ ] Improve text display (handle `\n` and terminal size) 9 | - [x] Checking feature 10 | - [ ] Configuration file? 11 | - [ ] Handle specific numbers for occurrences / lines? 12 | - [ ] Be extensible? 13 | 14 | - Handle script arguments: 15 | 16 | - [x] Format 17 | - [ ] Section order 18 | - [ ] Function section order 19 | - [x] Be nice when checking 20 | - [x] Warn (or not) when checking 21 | - [x] Fail at first warning when checking 22 | - [x] Ignore specific tags when checking 23 | 24 | Pull requests are welcomed! 25 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | # Usage on the Command-Line 2 | 3 | ``` 4 | Usage: shellman [-h] [-c CONTEXT [CONTEXT ...]] 5 | [--context-file CONTEXT_FILE] 6 | [-t TEMPLATE] [-m] [-o OUTPUT] 7 | [FILE [FILE ...]] 8 | ``` 9 | 10 | *Positional arguments:* 11 | 12 | - `FILE`: path to the file(s) to read. Use - to read on standard input. 13 | 14 | *Optional arguments:* 15 | 16 | - `-h, --help`: show this help message and exit 17 | - `-c, --context CONTEXT [CONTEXT ...]`: 18 | context to inject when rendering the template. You can 19 | pass JSON strings or key=value pairs. Example: 20 | `--context project=hello '{"version": [0, 3, 1]}'`. 21 | - `--context-file CONTEXT_FILE`: 22 | JSON file to read context from. By default shellman 23 | will try to read the file '.shellman.json' in the 24 | current directory. 25 | - `-t, --template TEMPLATE`: 26 | the Jinja2 template to use. Prefix with `path:` to 27 | specify the path to a custom template. Available 28 | templates: helptext, manpage, manpage.1, manpage.3, 29 | manpage.groff, manpage.markdown, manpage.md, wikipage, 30 | wikipage.markdown, wikipage.md 31 | - `-m, --merge`: 32 | with multiple input files, merge their contents in the 33 | output instead of appending (default: False). 34 | - `-o, --output OUTPUT`: 35 | file to write to (default: stdout). You can use the 36 | following variables in the output name: `{basename}`, 37 | `{ext}`, `{filename}` (equal to `{basename}.{ext}`), 38 | `{filepath}`, `{dirname}`, `{dirpath}`, and `{vcsroot}` 39 | (git and mercurial supported). They will be populated from 40 | each input file. 41 | 42 | 43 | ## Builtin templates 44 | 45 | The available builtin templates are: 46 | 47 | 1. `helptext`: A basic help text typically printed by scripts' `--help` option. 48 | 2. `manpage`: A Groff (GNU Troff) formatted file, suitable for `man`. 49 | 3. `wikipage`: A Markdown formatted file to be used in a project's online wiki. 50 | 51 | ## Custom templates 52 | 53 | Instead of using a builtin template, you can specify the path to a custom 54 | template that you wrote: 55 | 56 | ```bash 57 | shellman --template path:my/template 58 | ``` 59 | 60 | The given path can be absolute or relative. 61 | 62 | See [How to write a template plugin](plugins.md) on this wiki, 63 | and [Jinja2's documentation](https://jinja.palletsprojects.com/en/3.1.x/) for more 64 | information about how to write templates. 65 | 66 | You can also take a look at the source code for the builtin templates [on GitHub][github]. 67 | 68 | [github]: https://github.com/pawamoy/shellman/tree/master/src/shellman/templates/data 69 | 70 | ## Examples 71 | 72 | ### Basic usage 73 | 74 | Simply pass the path to your script to shellman: 75 | 76 | ```bash 77 | shellman my_script 78 | ``` 79 | 80 | The default template is `helptext`, so the previous example is equivalent to: 81 | 82 | ```bash 83 | shellman --template helptext my_script 84 | ``` 85 | 86 | Instead of using the shell's redirection (`>`, `>>`), 87 | you can pass the output path to the `-o, --output` option: 88 | 89 | ```bash 90 | shellman -t wikipage lib/base.sh -o ./wiki/base.sh.md 91 | ``` 92 | 93 | ### Previewing a man page 94 | 95 | Here is a simple trick to see how the man page would look using man: 96 | 97 | ```bash 98 | man <(shellman -t manpage my_script) 99 | ``` 100 | 101 | ### Multiple input files 102 | 103 | You can of course use shellman in a loop: 104 | 105 | ```bash 106 | for file in lib/*; do 107 | shellman $f 108 | done 109 | ``` 110 | 111 | ...but this would be inefficient because of the process' starting time being 112 | repeated for each file. 113 | 114 | The most efficient way is to pass the list of files directly as argument 115 | to shellman. 116 | 117 | ```bash 118 | shellman lib/* 119 | ``` 120 | 121 | #### Using variable output name 122 | 123 | This is especially useful when passing multiple files as input. 124 | The available variables are `{basename}`, 125 | `{ext}`, `{filename}` (equal to `{basename}.{ext}`), 126 | `{filepath}`, `{dirname}`, `{dirpath}` and `{vcsroot}`. 127 | 128 | ```bash 129 | shellman -t wikipage lib/* -o ./wiki/{filename}.md 130 | ``` 131 | 132 | #### Merging contents of multiple files 133 | 134 | By default, each input file is rendered separately, 135 | but you can ask shellman to merge the contents of multiple files 136 | before rendering. It is done with the `-m, --merge` option: 137 | 138 | ```bash 139 | shellman -m lib/* -o ./wiki/all_libs.md 140 | ``` 141 | 142 | It can be useful if you want to generate a single documentation 143 | page from code that is split across multiple files. 144 | 145 | Without the `-m` option, rendered contents for each input file 146 | is appended in the output file: 147 | 148 | ``` 149 | brief, desc, usage, ... for script 1 150 | brief, desc, usage, ... for script 2 151 | ... 152 | brief, desc, usage, ... for script n 153 | ``` 154 | 155 | #### Using shellman with find 156 | 157 | Let say you have a directory containing multiple git repositories. 158 | Each one of these repositories has a `lib` folder with shell libraries inside. 159 | You want to generate the man pages for each library file and output them in 160 | the respective man directories. 161 | 162 | ```bash 163 | shellman $(find my_dir -regex '.*/lib/.*\.sh') \ 164 | --template manpage \ 165 | --output {vcsroot}/man/{filename}.3 166 | ``` 167 | 168 | #### Using shellman with find and xargs 169 | 170 | If you have thousands of file to treat, 171 | the previous command could be too long for the interpreter. 172 | A solution is to split the command with xargs, 173 | treating 50 files at a time. 174 | 175 | ```bash 176 | find big_project -iname "*.sh" | xargs -n 50 \ 177 | shellman -twikipage -o big_project/wiki/{filename}.md 178 | ``` 179 | 180 | ### Using shellman in a Makefile 181 | 182 | If you are using a Makefile for your project, 183 | it could be interesting to add rules to (re)generate 184 | the documentation files when scripts or libraries 185 | have been updated. Here is an example of Makefile 186 | using shellman to update man pages and wiki pages: 187 | 188 | ```make 189 | # Declare project structure 190 | BINDIR := bin 191 | LIBDIR := lib 192 | MANDIR := man 193 | WIKIDIR := wiki 194 | 195 | # List scripts and libraries 196 | SCRIPTS := $(sort $(shell cd $(BINDIR) && ls)) 197 | LIBRARIES := $(sort $(shell cd $(LIBDIR) && ls)) 198 | 199 | # Declare related man pages and wikipages 200 | MANPAGES := $(addprefix $(MANDIR)/,$(addsuffix .1,$(SCRIPTS)) $(addsuffix .3,$(LIBRARIES))) 201 | WIKIPAGES := $(addprefix $(WIKIDIR)/,$(addsuffix .md,$(SCRIPTS)) $(addsuffix .md,$(LIBRARIES))) 202 | 203 | # Each man(1) page depends on its respective script 204 | $(MANDIR)/%.1: $(BINDIR)/% 205 | shellman -tmanpage $< -o $@ 206 | 207 | # Each man(3) page depends on its respective library 208 | $(MANDIR)/%.sh.3: $(LIBDIR)/%.sh 209 | shellman -tmanpage $< -o $@ 210 | 211 | # Each script wiki page depends on its respective script 212 | $(WIKIDIR)/%.md: $(BINDIR)/% 213 | shellman -twikipage $< -o $@ 214 | 215 | # Each library wiki page depends on its respective library 216 | $(WIKIDIR)/%.sh.md: $(LIBDIR)/%.sh 217 | shellman -twikipage $< -o $@ 218 | 219 | man: $(MANPAGES) 220 | 221 | wiki: $(WIKIPAGES) 222 | 223 | doc: man wiki 224 | ``` 225 | 226 | ### Playing with context 227 | 228 | When you render a template, you can change the values of the variables 229 | by injecting "context". 230 | What we call context here is simply a nested key-value list. 231 | There are three ways to inject extra context in a template: 232 | 233 | 1. with command-line arguments 234 | 2. with environment variables 235 | 3. with a JSON file 236 | 237 | The order of precedence is the same: 238 | CLI arguments have priority over environment variables, 239 | which have priority over JSON file context. 240 | 241 | #### Passing context with command-line arguments 242 | 243 | The option to pass context is `-c` or `--context`. 244 | It accepts one or more positional arguments. 245 | These positional arguments can have two forms: 246 | a JSON-formatted string or a KEY=VALUE string. 247 | The KEY part can be dot-separated to declare a nested item. 248 | The VALUE part will always be a string. 249 | 250 | Here are a few examples: 251 | 252 | ```bash 253 | # These two examples are equivalent 254 | shellman my_script --context '{"filename": "My Script"}' 255 | shellman my_script --context filename="My Script" 256 | ``` 257 | 258 | ```bash 259 | # These two examples are NOT equivalent 260 | shellman my_script --context '{"number": 0}' # number is integer 261 | shellman my_script --context number=0 # number is string 262 | ``` 263 | 264 | ```bash 265 | # These two examples are equivalent 266 | shellman my_script -c '{"some": {"nested": {"item": "value"}}}' 267 | shellman my_script -c some.nested.item=value 268 | ``` 269 | 270 | The context is recursively updated with each argument, 271 | so you can add values to dictionaries without erasing them. 272 | 273 | ```bash 274 | shellman my_script -c some.nested.item=value '{"some": {"other": {"item": 1}}}' some.hello=world 275 | ``` 276 | 277 | ```json 278 | { 279 | "some": { 280 | "nested": { 281 | "item": "value" 282 | }, 283 | "other": { 284 | "item": 1 285 | }, 286 | "hello": "world" 287 | } 288 | } 289 | ``` 290 | 291 | But of course, if you redefine the dictionary itself, 292 | all previous contents are lost: 293 | 294 | ```bash 295 | shellman my_script -c some.nested.item=value some=hello 296 | ``` 297 | 298 | ```json 299 | { 300 | "some": "hello" 301 | } 302 | ``` 303 | 304 | #### Passing context with environment variables 305 | 306 | Environment variables prefixed with `SHELLMAN_CONTEXT_` 307 | will be used to update the context. 308 | 309 | ```bash 310 | SHELLMAN_CONTEXT_HELLO=world shellman my_script 311 | ``` 312 | 313 | ```json 314 | { 315 | "hello": "world" 316 | } 317 | ``` 318 | 319 | As explained above, CLI arguments override environment variables. 320 | 321 | ```bash 322 | SHELLMAN_CONTEXT_HELLO=world shellman my_script -c hello=universe 323 | ``` 324 | 325 | ```json 326 | { 327 | "hello": "universe" 328 | } 329 | ``` 330 | 331 | There is currently no way to pass nested items with environment variables: 332 | 333 | ```bash 334 | SHELLMAN_CONTEXT_SOME_NESTED_ITEM=value shellman my_script 335 | ``` 336 | 337 | ```json 338 | { 339 | "some_nested_item": "value" 340 | } 341 | ``` 342 | 343 | #### Passing context with a JSON file 344 | 345 | By default, `shellman` will try to read context from a file 346 | in the current directory called `.shellman.json`. 347 | You can specify another file with the `--context-file` option. 348 | 349 | ```bash 350 | shellman my_script --context-file ./context/special.json 351 | ``` 352 | -------------------------------------------------------------------------------- /docs/usage/plugins.md: -------------------------------------------------------------------------------- 1 | # Write a template plugin 2 | 3 | To show how to write a template plugin, 4 | we will create a new, minimal Python package. 5 | 6 | Its structure will be like the following: 7 | 8 | ``` 9 | . 10 | ├── setup.py 11 | └── src 12 |    └── package_name 13 | ├── __init__.py 14 | └── data 15 | └── my_template 16 | ``` 17 | 18 | In `src/package_name/__init__.py`, 19 | we are simply going to import the `Template` class 20 | from `shellman`, and define an instance of it: 21 | 22 | ```python 23 | # __init__.py 24 | 25 | import os 26 | from shellman import Template 27 | 28 | data_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") 29 | 30 | my_template = Template(data_path, "my_template") 31 | ``` 32 | 33 | In `setup.py`, we then add a `shellman` entrypoint pointing to that template: 34 | 35 | ```python 36 | # setup.py 37 | 38 | from setuptools import setup 39 | 40 | setup(..., entrypoints={"shellman": ["my_template_name = package_name:my_template"]}) 41 | ``` 42 | 43 | Instead of pointing to an instance of Template, you can also point to 44 | a dictionary of templates. This is useful if you want to set aliases for 45 | the same template (like `my_template`, `my_template.md`, `my_template.markdown`). 46 | 47 | ```python 48 | # __init__.py 49 | 50 | import os 51 | from shellman import Template 52 | 53 | data_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") 54 | 55 | my_template = Template(data_path, "my_template") 56 | 57 | template_dict = { 58 | "my_template": my_template, 59 | "my_template.md": my_template, 60 | "my_template.markdown": my_template, 61 | } 62 | ``` 63 | 64 | ```python 65 | # setup.py 66 | 67 | from setuptools import setup 68 | 69 | setup(..., entrypoints={"shellman": ["unused_dict_name = package_name:template_dict"]}) 70 | ``` 71 | 72 | Similarly, you could do it with entrypoints only: 73 | 74 | ```python 75 | # setup.py 76 | 77 | from setuptools import setup 78 | 79 | setup( 80 | ..., 81 | entrypoints={ 82 | "shellman": [ 83 | "my_template = package_name:my_template", 84 | "my_template.md = package_name:my_template", 85 | "my_template.markdown = package_name:my_template", 86 | ] 87 | }, 88 | ) 89 | ``` 90 | 91 | ## The template itself 92 | 93 | Please read [Jinja2's documentation](http://jinja.pocoo.org/docs/2.10/) 94 | for more information about how to write templates. 95 | 96 | You can also take a look at the source code for the builtin templates [on GitHub][github]. 97 | 98 | [github]: https://github.com/pawamoy/shellman/tree/master/src/shellman/templates/data 99 | 100 | ## Adding context and Jinja filters 101 | 102 | You can specify a default context and default filters 103 | to use within your template: 104 | 105 | ```python 106 | def do_url(obj): 107 | return "https://{}/{}/{}".format(obj.domain, obj.namespace, obj.name) 108 | 109 | 110 | my_template = Template(data_path, "my_template", context={"indent": 4}, filters={"url": do_url}) 111 | ``` 112 | 113 | In your template, you will then have access to the `{{ my_object|url }}` filter, 114 | as well as the `{{ indent }}` variable, which could be used like 115 | `{{ indent * " " }}`. 116 | -------------------------------------------------------------------------------- /docs/usage/syntax.md: -------------------------------------------------------------------------------- 1 | # Documentation syntax 2 | 3 | To write your documentation, you must follow a few simple rules. 4 | 5 | 6 | - Documentation lines always begin with `##` and a space. 7 | ``` 8 | ## This is a doc line. 9 | # This is not a doc line. 10 | ##This is not valid because there is no space after ##. 11 | ``` 12 | 13 | - Documentation lines cannot be placed at the end of instructions. 14 | ``` 15 | ## This will be recognized. 16 | ## Even with spaces or tabs before. 17 | echo "This will NOT be recognized" ## Ignored 18 | ``` 19 | 20 | - Documentation **tags** are available to precise the type of documentation. 21 | Tags are always preceded with either ``@`` or ``\`` (at or backslash). 22 | Example: 23 | ``` 24 | ## @brief This file is the README. 25 | ## \desc I personally prefer backslash, I find it more readable. 26 | ``` 27 | 28 | - A documentation tag can have multiple lines of contents. 29 | ``` 30 | ## \bug First line. 31 | ## Second line. 32 | ## 33 | ## Fourth line. 34 | ``` 35 | 36 | - You can leave the first line blank though. 37 | ``` 38 | ## \bug 39 | ## First line. 40 | ## 41 | ## Third line. 42 | ``` 43 | 44 | - There is no restriction in the number of occurrences or number of lines per tag. 45 | ``` 46 | ## \brief Although only the first brief will be used in builtin templates... 47 | ## \brief ...you still can write more than one. 48 | ``` 49 | 50 | - Documentation lines without tags are always attached to the previous tag. 51 | ``` 52 | ## \note This is the first note. 53 | 54 | ## This is still the first note. 55 | ## \note This is another note. 56 | ``` 57 | 58 | - Tags can have sub-tags. The best example is the ``\function`` tag: 59 | ``` 60 | ## \function some prototype or else 61 | ## \function-brief one-line description 62 | ## \function-argument arg1 some argument 63 | ``` 64 | - When rendering a tag's contents as text, shellman will indent and wrap it. To prevent joining 65 | lines that should not be joined, simply indent them with one more space or tab. Also blank 66 | documentation lines are kept as blank lines. 67 | ``` 68 | ## \desc Starting a description. 69 | ## Showing a list of steps: 70 | ## 71 | ## - do this 72 | ## - and do that 73 | ``` 74 | 75 | That's it! You may want to take a look at the [available tags](tags.md) now. 76 | -------------------------------------------------------------------------------- /duties.py: -------------------------------------------------------------------------------- 1 | """Development tasks.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | import sys 8 | from contextlib import contextmanager 9 | from importlib.metadata import version as pkgversion 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING 12 | 13 | from duty import duty, tools 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | from duty.context import Context 19 | 20 | 21 | PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 22 | PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 23 | PY_SRC = " ".join(PY_SRC_LIST) 24 | CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 25 | WINDOWS = os.name == "nt" 26 | PTY = not WINDOWS and not CI 27 | MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 28 | 29 | 30 | def pyprefix(title: str) -> str: 31 | if MULTIRUN: 32 | prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 33 | return f"{prefix:14}{title}" 34 | return title 35 | 36 | 37 | @contextmanager 38 | def material_insiders() -> Iterator[bool]: 39 | if "+insiders" in pkgversion("mkdocs-material"): 40 | os.environ["MATERIAL_INSIDERS"] = "true" 41 | try: 42 | yield True 43 | finally: 44 | os.environ.pop("MATERIAL_INSIDERS") 45 | else: 46 | yield False 47 | 48 | 49 | def _get_changelog_version() -> str: 50 | changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 51 | with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: 52 | return next(filter(bool, map(changelog_version_re.match, file))).group(1) # type: ignore[union-attr] 53 | 54 | 55 | @duty 56 | def changelog(ctx: Context, bump: str = "") -> None: 57 | """Update the changelog in-place with latest commits. 58 | 59 | Parameters: 60 | bump: Bump option passed to git-changelog. 61 | """ 62 | ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 63 | ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") 64 | 65 | 66 | @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 67 | def check(ctx: Context) -> None: 68 | """Check it all!""" 69 | 70 | 71 | @duty 72 | def check_quality(ctx: Context) -> None: 73 | """Check the code quality.""" 74 | ctx.run( 75 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 76 | title=pyprefix("Checking code quality"), 77 | ) 78 | 79 | 80 | @duty 81 | def check_docs(ctx: Context) -> None: 82 | """Check if the documentation builds correctly.""" 83 | Path("htmlcov").mkdir(parents=True, exist_ok=True) 84 | Path("htmlcov/index.html").touch(exist_ok=True) 85 | with material_insiders(): 86 | ctx.run( 87 | tools.mkdocs.build(strict=True, verbose=True), 88 | title=pyprefix("Building documentation"), 89 | ) 90 | 91 | 92 | @duty 93 | def check_types(ctx: Context) -> None: 94 | """Check that the code is correctly typed.""" 95 | os.environ["FORCE_COLOR"] = "1" 96 | ctx.run( 97 | tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 98 | title=pyprefix("Type-checking"), 99 | ) 100 | 101 | 102 | @duty 103 | def check_api(ctx: Context, *cli_args: str) -> None: 104 | """Check for API breaking changes.""" 105 | ctx.run( 106 | tools.griffe.check("shellman", search=["src"], color=True).add_args(*cli_args), 107 | title="Checking for API breaking changes", 108 | nofail=True, 109 | ) 110 | 111 | 112 | @duty 113 | def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 114 | """Serve the documentation (localhost:8000). 115 | 116 | Parameters: 117 | host: The host to serve the docs from. 118 | port: The port to serve the docs on. 119 | """ 120 | with material_insiders(): 121 | ctx.run( 122 | tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 123 | title="Serving documentation", 124 | capture=False, 125 | ) 126 | 127 | 128 | @duty 129 | def docs_deploy(ctx: Context) -> None: 130 | """Deploy the documentation to GitHub pages.""" 131 | os.environ["DEPLOY"] = "true" 132 | with material_insiders() as insiders: 133 | if not insiders: 134 | ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 135 | ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 136 | 137 | 138 | @duty 139 | def format(ctx: Context) -> None: 140 | """Run formatting tools on the code.""" 141 | ctx.run( 142 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 143 | title="Auto-fixing code", 144 | ) 145 | ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 146 | 147 | 148 | @duty 149 | def build(ctx: Context) -> None: 150 | """Build source and wheel distributions.""" 151 | ctx.run( 152 | tools.build(), 153 | title="Building source and wheel distributions", 154 | pty=PTY, 155 | ) 156 | 157 | 158 | @duty 159 | def publish(ctx: Context) -> None: 160 | """Publish source and wheel distributions to PyPI.""" 161 | if not Path("dist").exists(): 162 | ctx.run("false", title="No distribution files found") 163 | dists = [str(dist) for dist in Path("dist").iterdir()] 164 | ctx.run( 165 | tools.twine.upload(*dists, skip_existing=True), 166 | title="Publishing source and wheel distributions to PyPI", 167 | pty=PTY, 168 | ) 169 | 170 | 171 | @duty(post=["build", "publish", "docs-deploy"]) 172 | def release(ctx: Context, version: str = "") -> None: 173 | """Release a new Python package. 174 | 175 | Parameters: 176 | version: The new version number to use. 177 | """ 178 | if not (version := (version or input("> Version to release: ")).strip()): 179 | ctx.run("false", title="A version must be provided") 180 | ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 181 | ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 182 | ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 183 | ctx.run("git push", title="Pushing commits", pty=False) 184 | ctx.run("git push --tags", title="Pushing tags", pty=False) 185 | 186 | 187 | @duty(silent=True, aliases=["cov"]) 188 | def coverage(ctx: Context) -> None: 189 | """Report coverage as text and HTML.""" 190 | ctx.run(tools.coverage.combine(), nofail=True) 191 | ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 192 | ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 193 | 194 | 195 | @duty 196 | def test(ctx: Context, *cli_args: str, match: str = "") -> None: 197 | """Run the test suite. 198 | 199 | Parameters: 200 | match: A pytest expression to filter selected tests. 201 | """ 202 | py_version = f"{sys.version_info.major}{sys.version_info.minor}" 203 | os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 204 | ctx.run( 205 | tools.pytest( 206 | "tests", 207 | config_file="config/pytest.ini", 208 | select=match, 209 | color="yes", 210 | ).add_args("-n", "auto", *cli_args), 211 | title=pyprefix("Running tests"), 212 | ) 213 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/shellman/0ac1cf54a47ee2a5910cf02642046f064c788da8/logo.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "shellman" 2 | site_description: "Write documentation in comments and render it with templates." 3 | site_url: "https://pawamoy.github.io/shellman" 4 | repo_url: "https://github.com/pawamoy/shellman" 5 | repo_name: "pawamoy/shellman" 6 | site_dir: "site" 7 | watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/shellman] 8 | copyright: Copyright © 2020 Timothée Mazzucotelli 9 | edit_uri: edit/main/docs/ 10 | 11 | validation: 12 | omitted_files: warn 13 | absolute_links: warn 14 | unrecognized_links: warn 15 | 16 | nav: 17 | - Home: 18 | - Overview: index.md 19 | - Changelog: changelog.md 20 | - Credits: credits.md 21 | - License: license.md 22 | - Todo: todo.md 23 | - Usage: 24 | - usage/index.md 25 | - Syntax: usage/syntax.md 26 | - Tags: usage/tags.md 27 | - Plugins: usage/plugins.md 28 | - API reference: reference/api.md 29 | - Development: 30 | - Contributing: contributing.md 31 | - Code of Conduct: code_of_conduct.md 32 | - Coverage report: coverage.md 33 | - Author's website: https://pawamoy.github.io/ 34 | 35 | theme: 36 | name: material 37 | custom_dir: docs/.overrides 38 | icon: 39 | logo: material/currency-sign 40 | features: 41 | - announce.dismiss 42 | - content.action.edit 43 | - content.action.view 44 | - content.code.annotate 45 | - content.code.copy 46 | - content.tooltips 47 | - navigation.footer 48 | - navigation.instant.preview 49 | - navigation.path 50 | - navigation.sections 51 | - navigation.tabs 52 | - navigation.tabs.sticky 53 | - navigation.top 54 | - search.highlight 55 | - search.suggest 56 | - toc.follow 57 | palette: 58 | - media: "(prefers-color-scheme)" 59 | toggle: 60 | icon: material/brightness-auto 61 | name: Switch to light mode 62 | - media: "(prefers-color-scheme: light)" 63 | scheme: default 64 | primary: teal 65 | accent: purple 66 | toggle: 67 | icon: material/weather-sunny 68 | name: Switch to dark mode 69 | - media: "(prefers-color-scheme: dark)" 70 | scheme: slate 71 | primary: black 72 | accent: lime 73 | toggle: 74 | icon: material/weather-night 75 | name: Switch to system preference 76 | 77 | extra_css: 78 | - css/material.css 79 | - css/mkdocstrings.css 80 | 81 | extra_javascript: 82 | - js/feedback.js 83 | 84 | markdown_extensions: 85 | - attr_list 86 | - admonition 87 | - callouts 88 | - footnotes 89 | - pymdownx.emoji: 90 | emoji_index: !!python/name:material.extensions.emoji.twemoji 91 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 92 | - pymdownx.magiclink 93 | - pymdownx.snippets: 94 | base_path: [!relative $config_dir] 95 | check_paths: true 96 | - pymdownx.superfences 97 | - pymdownx.tabbed: 98 | alternate_style: true 99 | slugify: !!python/object/apply:pymdownx.slugs.slugify 100 | kwds: 101 | case: lower 102 | - pymdownx.tasklist: 103 | custom_checkbox: true 104 | - toc: 105 | permalink: "¤" 106 | 107 | plugins: 108 | - search 109 | - autorefs 110 | - markdown-exec 111 | - section-index 112 | - coverage 113 | - mkdocstrings: 114 | handlers: 115 | python: 116 | inventories: 117 | - https://docs.python.org/3/objects.inv 118 | paths: [src] 119 | options: 120 | backlinks: tree 121 | docstring_options: 122 | ignore_init_summary: true 123 | docstring_section_style: list 124 | extensions: 125 | - griffe_inherited_docstrings 126 | filters: public 127 | heading_level: 1 128 | inherited_members: true 129 | merge_init_into_class: true 130 | separate_signature: true 131 | show_root_heading: true 132 | show_root_full_path: false 133 | show_signature_annotations: true 134 | show_source: true 135 | show_symbol_type_heading: true 136 | show_symbol_type_toc: true 137 | signature_crossrefs: true 138 | summary: true 139 | - llmstxt: 140 | files: 141 | - output: llms-full.txt 142 | inputs: 143 | - index.md 144 | - reference/**.md 145 | - git-revision-date-localized: 146 | enabled: !ENV [DEPLOY, false] 147 | enable_creation_date: true 148 | type: timeago 149 | - minify: 150 | minify_html: !ENV [DEPLOY, false] 151 | - group: 152 | enabled: !ENV [MATERIAL_INSIDERS, false] 153 | plugins: 154 | - typeset 155 | 156 | extra: 157 | social: 158 | - icon: fontawesome/brands/github 159 | link: https://github.com/pawamoy 160 | - icon: fontawesome/brands/mastodon 161 | link: https://fosstodon.org/@pawamoy 162 | - icon: fontawesome/brands/twitter 163 | link: https://twitter.com/pawamoy 164 | - icon: fontawesome/brands/gitter 165 | link: https://gitter.im/shellman/community 166 | - icon: fontawesome/brands/python 167 | link: https://pypi.org/project/shellman/ 168 | analytics: 169 | feedback: 170 | title: Was this page helpful? 171 | ratings: 172 | - icon: material/emoticon-happy-outline 173 | name: This page was helpful 174 | data: 1 175 | note: Thanks for your feedback! 176 | - icon: material/emoticon-sad-outline 177 | name: This page could be improved 178 | data: 0 179 | note: Let us know how we can improve this page. 180 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "shellman" 7 | description = "Write documentation in comments and render it with templates." 8 | authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] 9 | license = "ISC" 10 | license-files = ["LICENSE"] 11 | readme = "README.md" 12 | requires-python = ">=3.9" 13 | keywords = [] 14 | dynamic = ["version"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Topic :: Documentation", 28 | "Topic :: Software Development", 29 | "Topic :: Utilities", 30 | "Typing :: Typed", 31 | ] 32 | dependencies = [ 33 | "jinja2>=3", 34 | # YORE: EOL 3.9: Remove line. 35 | "importlib-metadata>=4.6; python_version < '3.10'", 36 | # YORE: EOL 3.10: Remove line. 37 | "typing-extensions>=4.0; python_version < '3.11'", 38 | ] 39 | 40 | [project.urls] 41 | Homepage = "https://pawamoy.github.io/shellman" 42 | Documentation = "https://pawamoy.github.io/shellman" 43 | Changelog = "https://pawamoy.github.io/shellman/changelog" 44 | Repository = "https://github.com/pawamoy/shellman" 45 | Issues = "https://github.com/pawamoy/shellman/issues" 46 | Discussions = "https://github.com/pawamoy/shellman/discussions" 47 | Gitter = "https://gitter.im/shellman/community" 48 | Funding = "https://github.com/sponsors/pawamoy" 49 | 50 | [project.scripts] 51 | shellman = "shellman:main" 52 | 53 | [tool.pdm.version] 54 | source = "call" 55 | getter = "scripts.get_version:get_version" 56 | 57 | [tool.pdm.build] 58 | # Include as much as possible in the source distribution, to help redistributors. 59 | excludes = ["**/.pytest_cache", "**/.mypy_cache"] 60 | source-includes = [ 61 | "config", 62 | "docs", 63 | "scripts", 64 | "share", 65 | "tests", 66 | "duties.py", 67 | "mkdocs.yml", 68 | "*.md", 69 | "LICENSE", 70 | ] 71 | 72 | [tool.pdm.build.wheel-data] 73 | # Manual pages can be included in the wheel. 74 | # Depending on the installation tool, they will be accessible to users. 75 | # pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. 76 | data = [ 77 | {path = "share/**/*", relative-to = "."}, 78 | ] 79 | 80 | [dependency-groups] 81 | maintain = [ 82 | "build>=1.2", 83 | "git-changelog>=2.5", 84 | "twine>=5.1", 85 | "yore>=0.3.3", 86 | ] 87 | ci = [ 88 | "duty>=1.6", 89 | "ruff>=0.4", 90 | "pytest>=8.2", 91 | "pytest-cov>=5.0", 92 | "pytest-randomly>=3.15", 93 | "pytest-xdist>=3.6", 94 | "mypy>=1.10", 95 | "types-markdown>=3.6", 96 | "types-pyyaml>=6.0", 97 | ] 98 | docs = [ 99 | "griffe-inherited-docstrings>=1.1", 100 | "markdown-callouts>=0.4", 101 | "markdown-exec>=1.8", 102 | "mkdocs>=1.6", 103 | "mkdocs-coverage>=1.0", 104 | "mkdocs-git-revision-date-localized-plugin>=1.2", 105 | "mkdocs-llmstxt>=0.1", 106 | "mkdocs-material>=9.5", 107 | "mkdocs-minify-plugin>=0.8", 108 | "mkdocs-section-index>=0.3", 109 | "mkdocstrings[python]>=0.29", 110 | # YORE: EOL 3.10: Remove line. 111 | "tomli>=2.0; python_version < '3.11'", 112 | ] 113 | 114 | [tool.uv] 115 | default-groups = ["maintain", "ci", "docs"] 116 | -------------------------------------------------------------------------------- /scripts/gen_credits.py: -------------------------------------------------------------------------------- 1 | # Script to generate the project's credits. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from collections import defaultdict 8 | from collections.abc import Iterable 9 | from importlib.metadata import distributions 10 | from itertools import chain 11 | from pathlib import Path 12 | from textwrap import dedent 13 | from typing import Union 14 | 15 | from jinja2 import StrictUndefined 16 | from jinja2.sandbox import SandboxedEnvironment 17 | from packaging.requirements import Requirement 18 | 19 | # YORE: EOL 3.10: Replace block with line 2. 20 | if sys.version_info >= (3, 11): 21 | import tomllib 22 | else: 23 | import tomli as tomllib 24 | 25 | project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) 26 | with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: 27 | pyproject = tomllib.load(pyproject_file) 28 | project = pyproject["project"] 29 | project_name = project["name"] 30 | devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")] 31 | 32 | PackageMetadata = dict[str, Union[str, Iterable[str]]] 33 | Metadata = dict[str, PackageMetadata] 34 | 35 | 36 | def _merge_fields(metadata: dict) -> PackageMetadata: 37 | fields = defaultdict(list) 38 | for header, value in metadata.items(): 39 | fields[header.lower()].append(value.strip()) 40 | return { 41 | field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] 42 | for field, value in fields.items() 43 | } 44 | 45 | 46 | def _norm_name(name: str) -> str: 47 | return name.replace("_", "-").replace(".", "-").lower() 48 | 49 | 50 | def _requirements(deps: list[str]) -> dict[str, Requirement]: 51 | return {_norm_name((req := Requirement(dep)).name): req for dep in deps} 52 | 53 | 54 | def _extra_marker(req: Requirement) -> str | None: 55 | if not req.marker: 56 | return None 57 | try: 58 | return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") 59 | except StopIteration: 60 | return None 61 | 62 | 63 | def _get_metadata() -> Metadata: 64 | metadata = {} 65 | for pkg in distributions(): 66 | name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] 67 | metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] 68 | metadata[name]["spec"] = set() 69 | metadata[name]["extras"] = set() 70 | metadata[name].setdefault("summary", "") 71 | _set_license(metadata[name]) 72 | return metadata 73 | 74 | 75 | def _set_license(metadata: PackageMetadata) -> None: 76 | license_field = metadata.get("license-expression", metadata.get("license", "")) 77 | license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) 78 | check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") 79 | if check_classifiers: 80 | license_names = [] 81 | for classifier in metadata["classifier"]: 82 | if classifier.startswith("License ::"): 83 | license_names.append(classifier.rsplit("::", 1)[1].strip()) 84 | license_name = " + ".join(license_names) 85 | metadata["license"] = license_name or "?" 86 | 87 | 88 | def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: 89 | deps = {} 90 | for dep_name, dep_req in base_deps.items(): 91 | if dep_name not in metadata or dep_name == "shellman": 92 | continue 93 | metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] 94 | metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] 95 | deps[dep_name] = metadata[dep_name] 96 | 97 | again = True 98 | while again: 99 | again = False 100 | for pkg_name in metadata: 101 | if pkg_name in deps: 102 | for pkg_dependency in metadata[pkg_name].get("requires-dist", []): 103 | requirement = Requirement(pkg_dependency) 104 | dep_name = _norm_name(requirement.name) 105 | extra_marker = _extra_marker(requirement) 106 | if ( 107 | dep_name in metadata 108 | and dep_name not in deps 109 | and dep_name != project["name"] 110 | and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) 111 | ): 112 | metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] 113 | deps[dep_name] = metadata[dep_name] 114 | again = True 115 | 116 | return deps 117 | 118 | 119 | def _render_credits() -> str: 120 | metadata = _get_metadata() 121 | dev_dependencies = _get_deps(_requirements(devdeps), metadata) 122 | prod_dependencies = _get_deps( 123 | _requirements( 124 | chain( # type: ignore[arg-type] 125 | project.get("dependencies", []), 126 | chain(*project.get("optional-dependencies", {}).values()), 127 | ), 128 | ), 129 | metadata, 130 | ) 131 | 132 | template_data = { 133 | "project_name": project_name, 134 | "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 135 | "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 136 | "more_credits": "http://pawamoy.github.io/credits/", 137 | } 138 | template_text = dedent( 139 | """ 140 | # Credits 141 | 142 | These projects were used to build *{{ project_name }}*. **Thank you!** 143 | 144 | [Python](https://www.python.org/) | 145 | [uv](https://github.com/astral-sh/uv) | 146 | [copier-uv](https://github.com/pawamoy/copier-uv) 147 | 148 | {% macro dep_line(dep) -%} 149 | [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} 150 | {%- endmacro %} 151 | 152 | {% if prod_dependencies -%} 153 | ### Runtime dependencies 154 | 155 | Project | Summary | Version (accepted) | Version (last resolved) | License 156 | ------- | ------- | ------------------ | ----------------------- | ------- 157 | {% for dep in prod_dependencies -%} 158 | {{ dep_line(dep) }} 159 | {% endfor %} 160 | 161 | {% endif -%} 162 | {% if dev_dependencies -%} 163 | ### Development dependencies 164 | 165 | Project | Summary | Version (accepted) | Version (last resolved) | License 166 | ------- | ------- | ------------------ | ----------------------- | ------- 167 | {% for dep in dev_dependencies -%} 168 | {{ dep_line(dep) }} 169 | {% endfor %} 170 | 171 | {% endif -%} 172 | {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} 173 | """, 174 | ) 175 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 176 | return jinja_env.from_string(template_text).render(**template_data) 177 | 178 | 179 | print(_render_credits()) 180 | -------------------------------------------------------------------------------- /scripts/get_version.py: -------------------------------------------------------------------------------- 1 | # Get current project version from Git tags or changelog. 2 | 3 | import re 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | from pdm.backend.hooks.version import SCMVersion, Version, default_version_formatter, get_version_from_scm 8 | 9 | _root = Path(__file__).parent.parent 10 | _changelog = _root / "CHANGELOG.md" 11 | _changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 12 | _default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 13 | 14 | 15 | def get_version() -> str: 16 | scm_version = get_version_from_scm(_root) or _default_scm_version 17 | if scm_version.version <= Version("0.1"): # Missing Git tags? 18 | with suppress(OSError, StopIteration): # noqa: SIM117 19 | with _changelog.open("r", encoding="utf8") as file: 20 | match = next(filter(None, map(_changelog_version_re.match, file))) 21 | scm_version = scm_version._replace(version=Version(match.group(1))) 22 | return default_version_formatter(scm_version) 23 | 24 | 25 | if __name__ == "__main__": 26 | print(get_version()) 27 | -------------------------------------------------------------------------------- /scripts/make: -------------------------------------------------------------------------------- 1 | make.py -------------------------------------------------------------------------------- /scripts/make.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from textwrap import dedent 11 | from typing import TYPE_CHECKING, Any 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | 16 | 17 | PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() 18 | 19 | 20 | def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None: 21 | """Run a shell command.""" 22 | if capture_output: 23 | return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 24 | subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 25 | return None 26 | 27 | 28 | @contextmanager 29 | def environ(**kwargs: str) -> Iterator[None]: 30 | """Temporarily set environment variables.""" 31 | original = dict(os.environ) 32 | os.environ.update(kwargs) 33 | try: 34 | yield 35 | finally: 36 | os.environ.clear() 37 | os.environ.update(original) 38 | 39 | 40 | def uv_install(venv: Path) -> None: 41 | """Install dependencies using uv.""" 42 | with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): 43 | if "CI" in os.environ: 44 | shell("uv sync --no-editable") 45 | else: 46 | shell("uv sync") 47 | 48 | 49 | def setup() -> None: 50 | """Setup the project.""" 51 | if not shutil.which("uv"): 52 | raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") 53 | 54 | print("Installing dependencies (default environment)") 55 | default_venv = Path(".venv") 56 | if not default_venv.exists(): 57 | shell("uv venv") 58 | uv_install(default_venv) 59 | 60 | if PYTHON_VERSIONS: 61 | for version in PYTHON_VERSIONS: 62 | print(f"\nInstalling dependencies (python{version})") 63 | venv_path = Path(f".venvs/{version}") 64 | if not venv_path.exists(): 65 | shell(f"uv venv --python {version} {venv_path}") 66 | with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): 67 | uv_install(venv_path) 68 | 69 | 70 | def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: 71 | """Run a command in a virtual environment.""" 72 | kwargs = {"check": True, **kwargs} 73 | uv_run = ["uv", "run", "--no-sync"] 74 | if version == "default": 75 | with environ(UV_PROJECT_ENVIRONMENT=".venv"): 76 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 77 | else: 78 | with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): 79 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 80 | 81 | 82 | def multirun(cmd: str, *args: str, **kwargs: Any) -> None: 83 | """Run a command for all configured Python versions.""" 84 | if PYTHON_VERSIONS: 85 | for version in PYTHON_VERSIONS: 86 | run(version, cmd, *args, **kwargs) 87 | else: 88 | run("default", cmd, *args, **kwargs) 89 | 90 | 91 | def allrun(cmd: str, *args: str, **kwargs: Any) -> None: 92 | """Run a command in all virtual environments.""" 93 | run("default", cmd, *args, **kwargs) 94 | if PYTHON_VERSIONS: 95 | multirun(cmd, *args, **kwargs) 96 | 97 | 98 | def clean() -> None: 99 | """Delete build artifacts and cache files.""" 100 | paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] 101 | for path in paths_to_clean: 102 | shutil.rmtree(path, ignore_errors=True) 103 | 104 | cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} 105 | for dirpath in Path(".").rglob("*/"): 106 | if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: 107 | shutil.rmtree(dirpath, ignore_errors=True) 108 | 109 | 110 | def vscode() -> None: 111 | """Configure VSCode to work on this project.""" 112 | shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) 113 | 114 | 115 | def main() -> int: 116 | """Main entry point.""" 117 | args = list(sys.argv[1:]) 118 | if not args or args[0] == "help": 119 | if len(args) > 1: 120 | run("default", "duty", "--help", args[1]) 121 | else: 122 | print( 123 | dedent( 124 | """ 125 | Available commands 126 | help Print this help. Add task name to print help. 127 | setup Setup all virtual environments (install dependencies). 128 | run Run a command in the default virtual environment. 129 | multirun Run a command for all configured Python versions. 130 | allrun Run a command in all virtual environments. 131 | 3.x Run a command in the virtual environment for Python 3.x. 132 | clean Delete build artifacts and cache files. 133 | vscode Configure VSCode to work on this project. 134 | """, 135 | ), 136 | flush=True, 137 | ) 138 | if os.path.exists(".venv"): 139 | print("\nAvailable tasks", flush=True) 140 | run("default", "duty", "--list") 141 | return 0 142 | 143 | while args: 144 | cmd = args.pop(0) 145 | 146 | if cmd == "run": 147 | run("default", *args) 148 | return 0 149 | 150 | if cmd == "multirun": 151 | multirun(*args) 152 | return 0 153 | 154 | if cmd == "allrun": 155 | allrun(*args) 156 | return 0 157 | 158 | if cmd.startswith("3."): 159 | run(cmd, *args) 160 | return 0 161 | 162 | opts = [] 163 | while args and (args[0].startswith("-") or "=" in args[0]): 164 | opts.append(args.pop(0)) 165 | 166 | if cmd == "clean": 167 | clean() 168 | elif cmd == "setup": 169 | setup() 170 | elif cmd == "vscode": 171 | vscode() 172 | elif cmd == "check": 173 | multirun("duty", "check-quality", "check-types", "check-docs") 174 | run("default", "duty", "check-api") 175 | elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 176 | multirun("duty", cmd, *opts) 177 | else: 178 | run("default", "duty", cmd, *opts) 179 | 180 | return 0 181 | 182 | 183 | if __name__ == "__main__": 184 | try: 185 | sys.exit(main()) 186 | except subprocess.CalledProcessError as process: 187 | if process.output: 188 | print(process.output, file=sys.stderr) 189 | sys.exit(process.returncode) 190 | -------------------------------------------------------------------------------- /src/shellman/__init__.py: -------------------------------------------------------------------------------- 1 | """shellman package. 2 | 3 | Read documentation from shell script comments and render it with templates. 4 | 5 | shellman reads specified FILEs and searches for special comments 6 | beginning with two sharps (##). 7 | It extracts documentation from these comment lines, 8 | and then generate a document by rendering a template. 9 | The template rendering is done with Jinja2. 10 | See https://jinja.palletsprojects.com/en/3.1.x/. 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | from shellman._internal.cli import get_parser, main 16 | from shellman._internal.context import DEFAULT_JSON_FILE, ENV_VAR_PREFIX 17 | from shellman._internal.reader import ( 18 | DocBlock, 19 | DocFile, 20 | DocLine, 21 | DocStream, 22 | DocType, 23 | tag_no_value_regex, 24 | tag_value_regex, 25 | ) 26 | from shellman._internal.tags import ( 27 | TAGS, 28 | AuthorTag, 29 | BriefTag, 30 | BugTag, 31 | CaveatTag, 32 | CopyrightTag, 33 | DateTag, 34 | DescTag, 35 | EnvTag, 36 | ErrorTag, 37 | ExampleTag, 38 | ExitTag, 39 | FileTag, 40 | FunctionTag, 41 | HistoryTag, 42 | LicenseTag, 43 | NoteTag, 44 | OptionTag, 45 | SeealsoTag, 46 | StderrTag, 47 | StdinTag, 48 | StdoutTag, 49 | Tag, 50 | TextTag, 51 | UsageTag, 52 | ValueDescTag, 53 | VersionTag, 54 | ) 55 | from shellman._internal.templates import ( 56 | Template, 57 | builtin_env, 58 | helptext, 59 | manpage, 60 | manpage_md, 61 | templates, 62 | usagetext, 63 | wikipage, 64 | ) 65 | from shellman._internal.templates.filters import ( 66 | FILTERS, 67 | console_width, 68 | do_body, 69 | do_escape, 70 | do_firstline, 71 | do_firstword, 72 | do_format, 73 | do_groffauto, 74 | do_groffautoemphasis, 75 | do_groffautoescape, 76 | do_groffautostrong, 77 | do_groffemphasis, 78 | do_groffstrong, 79 | do_groupby, 80 | do_smartwrap, 81 | ) 82 | 83 | __all__: list[str] = [ 84 | "DEFAULT_JSON_FILE", 85 | "ENV_VAR_PREFIX", 86 | "FILTERS", 87 | "TAGS", 88 | "AuthorTag", 89 | "BriefTag", 90 | "BugTag", 91 | "CaveatTag", 92 | "CopyrightTag", 93 | "DateTag", 94 | "DescTag", 95 | "DocBlock", 96 | "DocFile", 97 | "DocLine", 98 | "DocStream", 99 | "DocType", 100 | "EnvTag", 101 | "ErrorTag", 102 | "ExampleTag", 103 | "ExitTag", 104 | "FileTag", 105 | "FunctionTag", 106 | "HistoryTag", 107 | "LicenseTag", 108 | "NoteTag", 109 | "OptionTag", 110 | "SeealsoTag", 111 | "StderrTag", 112 | "StdinTag", 113 | "StdoutTag", 114 | "Tag", 115 | "Template", 116 | "TextTag", 117 | "UsageTag", 118 | "ValueDescTag", 119 | "VersionTag", 120 | "builtin_env", 121 | "console_width", 122 | "do_body", 123 | "do_escape", 124 | "do_firstline", 125 | "do_firstword", 126 | "do_format", 127 | "do_groffauto", 128 | "do_groffautoemphasis", 129 | "do_groffautoescape", 130 | "do_groffautostrong", 131 | "do_groffemphasis", 132 | "do_groffstrong", 133 | "do_groupby", 134 | "do_smartwrap", 135 | "get_parser", 136 | "helptext", 137 | "main", 138 | "manpage", 139 | "manpage_md", 140 | "tag_no_value_regex", 141 | "tag_value_regex", 142 | "templates", 143 | "usagetext", 144 | "wikipage", 145 | ] 146 | -------------------------------------------------------------------------------- /src/shellman/__main__.py: -------------------------------------------------------------------------------- 1 | """Entry-point module, in case you use `python -m shellman`. 2 | 3 | Why does this file exist, and why `__main__`? For more info, read: 4 | 5 | - https://www.python.org/dev/peps/pep-0338/ 6 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 7 | """ 8 | 9 | import sys 10 | 11 | from shellman._internal.cli import main 12 | 13 | if __name__ == "__main__": 14 | sys.exit(main(sys.argv[1:])) 15 | -------------------------------------------------------------------------------- /src/shellman/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/shellman/0ac1cf54a47ee2a5910cf02642046f064c788da8/src/shellman/_internal/__init__.py -------------------------------------------------------------------------------- /src/shellman/_internal/cli.py: -------------------------------------------------------------------------------- 1 | # Why does this file exist, and why not put this in `__main__`? 2 | # 3 | # You might be tempted to import things from `__main__` later, 4 | # but that will cause problems: the code will get executed twice: 5 | # 6 | # - When you run `python -m shellman` python will execute 7 | # `__main__.py` as a script. That means there won't be any 8 | # `shellman.__main__` in `sys.modules`. 9 | # - When you import `__main__` it will get executed again (as a module) because 10 | # there's no `shellman.__main__` in `sys.modules`. 11 | 12 | from __future__ import annotations 13 | 14 | import argparse 15 | import os 16 | import re 17 | import sys 18 | from datetime import datetime, timezone 19 | from typing import TYPE_CHECKING, Any 20 | 21 | from shellman._internal import debug, templates 22 | from shellman._internal.context import DEFAULT_JSON_FILE, _get_context, _update 23 | from shellman._internal.reader import DocFile, DocStream, _merge 24 | 25 | if TYPE_CHECKING: 26 | from collections.abc import Sequence 27 | 28 | from shellman._internal.templates import Template 29 | 30 | 31 | class _DebugInfo(argparse.Action): 32 | def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 33 | super().__init__(nargs=nargs, **kwargs) 34 | 35 | def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 36 | debug._print_debug_info() 37 | sys.exit(0) 38 | 39 | 40 | def _valid_file(value: str) -> str: 41 | if value == "-": 42 | return value 43 | if not value: 44 | raise argparse.ArgumentTypeError("'' is not a valid file path") 45 | if not os.path.exists(value): 46 | raise argparse.ArgumentTypeError(f"{value} is not a valid file path") 47 | if os.path.isdir(value): 48 | raise argparse.ArgumentTypeError(f"{value} is a directory, not a regular file") 49 | return value 50 | 51 | 52 | def get_parser() -> argparse.ArgumentParser: 53 | """Return the CLI argument parser. 54 | 55 | Returns: 56 | An argparse parser. 57 | """ 58 | parser = argparse.ArgumentParser(prog="shellman") 59 | 60 | parser.add_argument( 61 | "-c", 62 | "--context", 63 | dest="context", 64 | nargs="+", 65 | help="context to inject when rendering the template. " 66 | "You can pass JSON strings or key=value pairs. " 67 | "Example: `--context project=hello '{\"version\": [0, 3, 1]}'`.", 68 | ) 69 | 70 | parser.add_argument( 71 | "--context-file", 72 | dest="context_file", 73 | help="JSON file to read context from. " 74 | f"By default shellman will try to read the file '{DEFAULT_JSON_FILE}' " 75 | "in the current directory.", 76 | ) 77 | 78 | parser.add_argument( 79 | "-t", 80 | "--template", 81 | metavar="TEMPLATE", 82 | choices=templates._parser_choices(), 83 | default="helptext", 84 | dest="template", 85 | help="the Jinja2 template to use. " 86 | 'Prefix with "path:" to specify the path ' 87 | "to a custom template. " 88 | f"Available templates: {', '.join(templates._names())}", 89 | ) 90 | 91 | parser.add_argument( 92 | "-m", 93 | "--merge", 94 | dest="merge", 95 | action="store_true", 96 | help="with multiple input files, merge their contents in the output " 97 | "instead of appending (default: %(default)s). ", 98 | ) 99 | 100 | parser.add_argument( 101 | "-o", 102 | "--output", 103 | action="store", 104 | dest="output", 105 | default=None, 106 | help="file to write to (default: stdout). " 107 | "You can use the following variables in the output name: " 108 | "{basename}, {ext}, {filename} (equal to {basename}.{ext}), " 109 | "{filepath}, {dirname}, {dirpath}, and {vcsroot} " 110 | "(git and mercurial supported). " 111 | "They will be populated from each input file.", 112 | ) 113 | parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}") 114 | parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") 115 | 116 | parser.add_argument( 117 | "FILE", 118 | type=_valid_file, 119 | nargs="*", 120 | help="path to the file(s) to read. Use - to read on standard input.", 121 | ) 122 | return parser 123 | 124 | 125 | def _render(template: Template, doc: DocFile | DocStream | None = None, **context: dict) -> str: 126 | shellman: dict[str, Any] = {"doc": {}} 127 | if doc is not None: 128 | shellman["doc"] = doc.sections 129 | shellman["filename"] = doc.filename 130 | shellman["filepath"] = doc.filepath 131 | shellman["today"] = datetime.now(tz=timezone.utc).date() 132 | shellman["version"] = debug._get_version() 133 | 134 | if "shellman" in context: 135 | _update(shellman, context.pop("shellman")) 136 | 137 | return template.render(shellman=shellman, **context) 138 | 139 | 140 | def _write(contents: str, filepath: str) -> None: 141 | with open(filepath, "w", encoding="utf-8") as write_stream: 142 | print(contents, file=write_stream) 143 | 144 | 145 | def _common_ancestor(docs: Sequence[DocFile | DocStream]) -> str: 146 | splits: list[tuple[str, str]] = [os.path.split(doc.filepath) for doc in docs if doc.filepath] 147 | vertical = [] 148 | depth = 1 149 | while True: 150 | if not all(len(s) >= depth for s in splits): 151 | break 152 | vertical.append([s[depth - 1] for s in splits]) 153 | depth += 1 154 | common = "" 155 | for vert in vertical: 156 | if vert.count(vert[0]) != len(vert): 157 | break 158 | common = vert[0] 159 | return common or "" 160 | 161 | 162 | def _is_format_string(string: str) -> bool: 163 | return bool(re.search(r"{[a-zA-Z_][\w]*}", string)) 164 | 165 | 166 | def _guess_filename(output: str, docs: Sequence[DocFile | DocStream] | None = None) -> str: 167 | if output and not _is_format_string(output): 168 | return os.path.basename(output) 169 | if docs: 170 | return _common_ancestor(docs) 171 | return "" 172 | 173 | 174 | def _output_name_variables(doc: DocFile | DocStream | None = None) -> dict: 175 | if doc: 176 | basename, ext = os.path.splitext(doc.filename) 177 | abspath = os.path.abspath(doc.filepath or doc.filename) 178 | dirpath = os.path.split(abspath)[0] or "." 179 | dirname = os.path.basename(dirpath) 180 | return { 181 | "filename": doc.filename, 182 | "filepath": abspath, 183 | "basename": basename, 184 | "ext": ext, 185 | "dirpath": dirpath, 186 | "dirname": dirname, 187 | "vcsroot": _get_vcs_root(dirpath), 188 | } 189 | return {} 190 | 191 | 192 | _vcs_root_cache: dict[str, str] = {} 193 | 194 | 195 | def _get_vcs_root(path: str) -> str: 196 | if path in _vcs_root_cache: 197 | return _vcs_root_cache[path] 198 | original_path = path 199 | while not any(os.path.exists(os.path.join(path, vcs)) for vcs in (".git", ".hg", ".svn")): 200 | path = os.path.dirname(path) 201 | if path == "/": 202 | path = "" 203 | break 204 | _vcs_root_cache[original_path] = path 205 | return path 206 | 207 | 208 | def main(args: list[str] | None = None) -> int: 209 | """Run the main program. 210 | 211 | This function is executed when you type `shellman` or `python -m shellman`. 212 | 213 | Get the file to parse, construct a Doc object, get file's doc, 214 | get the according formatter class, instantiate it 215 | with acquired doc and write on specified file (stdout by default). 216 | 217 | Parameters: 218 | args: Arguments passed from the command line. 219 | 220 | Returns: 221 | An exit code. 222 | """ 223 | templates._load_plugin_templates() 224 | 225 | parser = get_parser() 226 | opts = parser.parse_args(args) 227 | 228 | # Catch errors as early as possible 229 | if opts.merge and len(opts.FILE) < 2: # noqa: PLR2004 230 | print( 231 | "shellman: warning: --merge option is ignored with less than 2 inputs", 232 | file=sys.stderr, 233 | ) 234 | 235 | if not opts.FILE and opts.output and _is_format_string(opts.output): 236 | parser.print_usage(file=sys.stderr) 237 | print( 238 | "shellman: error: cannot format output name without file inputs. " 239 | "Please remove variables from output name, or provide file inputs", 240 | file=sys.stderr, 241 | ) 242 | return 2 243 | 244 | # Immediately get the template to throw error if not found 245 | if opts.template.startswith("path:"): 246 | template = templates._get_custom_template(opts.template[5:]) 247 | else: 248 | template = templates.templates[opts.template] 249 | 250 | context = _get_context(opts) 251 | 252 | # Render template with context only 253 | if not opts.FILE: 254 | if not context: 255 | parser.print_usage(file=sys.stderr) 256 | print("shellman: error: please specify input file(s) or context", file=sys.stderr) 257 | return 1 258 | contents = _render(template, None, **context) 259 | if opts.output: 260 | _write(contents, opts.output) 261 | else: 262 | print(contents) 263 | return 0 264 | 265 | # Parse input files 266 | docs: list[DocFile | DocStream] = [] 267 | for file in opts.FILE: 268 | if file == "-": 269 | docs.append(DocStream(sys.stdin, filename=_guess_filename(opts.output))) 270 | else: 271 | docs.append(DocFile(file)) 272 | 273 | # Optionally merge the parsed contents 274 | if opts.merge: 275 | new_filename = _guess_filename(opts.output, docs) 276 | docs = [_merge(docs, new_filename)] 277 | 278 | # If opts.output contains variables, each input has its own output 279 | if opts.output and _is_format_string(opts.output): 280 | for doc in docs: 281 | _write( 282 | _render(template, doc, **context), 283 | opts.output.format(**_output_name_variables(doc)), 284 | ) 285 | # Else, concatenate contents (no effect if already merged), then output to file or stdout 286 | else: 287 | contents = "\n\n\n".join(_render(template, doc, **context) for doc in docs) 288 | if opts.output: 289 | _write(contents, opts.output) 290 | else: 291 | print(contents) 292 | 293 | return 0 294 | -------------------------------------------------------------------------------- /src/shellman/_internal/context.py: -------------------------------------------------------------------------------- 1 | # Jinja-context related utilities. 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import json 7 | import os 8 | from typing import TYPE_CHECKING, Any 9 | 10 | if TYPE_CHECKING: 11 | import argparse 12 | from collections.abc import Sequence 13 | 14 | ENV_VAR_PREFIX = "SHELLMAN_CONTEXT_" 15 | """The prefix for environment variables that will be used as context.""" 16 | DEFAULT_JSON_FILE = ".shellman.json" 17 | """The default JSON file to read context from.""" 18 | 19 | 20 | def _get_cli_context(args: Sequence[str]) -> dict: 21 | context: dict[str, Any] = {} 22 | if args: 23 | for context_arg in args: 24 | if not context_arg: 25 | continue 26 | if context_arg[0] == "{": 27 | context.update(json.loads(context_arg)) 28 | elif "=" in context_arg: 29 | name, value = context_arg.split("=", 1) 30 | if "." in name: 31 | name_dict: dict[str, Any] = {} 32 | d = name_dict 33 | parts = name.split(".") 34 | for name_part in parts[1:-1]: 35 | d[name_part] = d = {} 36 | d[parts[-1]] = value 37 | context[parts[0]] = name_dict 38 | else: 39 | context[name] = value 40 | # else invalid arg 41 | return context 42 | 43 | 44 | def _get_env_context() -> dict: 45 | context = {} 46 | for env_name, env_value in os.environ.items(): 47 | if env_name.startswith(ENV_VAR_PREFIX): 48 | context_var_name = env_name[len(ENV_VAR_PREFIX) :].lower() 49 | context[context_var_name] = env_value 50 | return context 51 | 52 | 53 | def _get_file_context(file: str) -> dict: 54 | with open(file) as stream: 55 | return json.load(stream) 56 | 57 | 58 | def _get_context(args: argparse.Namespace) -> dict: 59 | context = {} 60 | 61 | if args.context_file: 62 | context.update(_get_file_context(args.context_file)) 63 | else: 64 | with contextlib.suppress(OSError): 65 | context.update(_get_file_context(DEFAULT_JSON_FILE)) 66 | 67 | _update(context, _get_env_context()) 68 | _update(context, _get_cli_context(args.context)) 69 | 70 | return context 71 | 72 | 73 | def _update(base: dict, added: dict) -> dict: 74 | for key, value in added.items(): 75 | if isinstance(value, dict): 76 | base[key] = _update(base.get(key, {}), value) 77 | else: 78 | base[key] = value 79 | return base 80 | -------------------------------------------------------------------------------- /src/shellman/_internal/debug.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | from dataclasses import dataclass 7 | from importlib import metadata 8 | 9 | 10 | @dataclass 11 | class _Variable: 12 | """Dataclass describing an environment variable.""" 13 | 14 | name: str 15 | """Variable name.""" 16 | value: str 17 | """Variable value.""" 18 | 19 | 20 | @dataclass 21 | class _Package: 22 | """Dataclass describing a Python package.""" 23 | 24 | name: str 25 | """Package name.""" 26 | version: str 27 | """Package version.""" 28 | 29 | 30 | @dataclass 31 | class _Environment: 32 | """Dataclass to store environment information.""" 33 | 34 | interpreter_name: str 35 | """Python interpreter name.""" 36 | interpreter_version: str 37 | """Python interpreter version.""" 38 | interpreter_path: str 39 | """Path to Python executable.""" 40 | platform: str 41 | """Operating System.""" 42 | packages: list[_Package] 43 | """Installed packages.""" 44 | variables: list[_Variable] 45 | """Environment variables.""" 46 | 47 | 48 | def _interpreter_name_version() -> tuple[str, str]: 49 | if hasattr(sys, "implementation"): 50 | impl = sys.implementation.version 51 | version = f"{impl.major}.{impl.minor}.{impl.micro}" 52 | kind = impl.releaselevel 53 | if kind != "final": 54 | version += kind[0] + str(impl.serial) 55 | return sys.implementation.name, version 56 | return "", "0.0.0" 57 | 58 | 59 | def _get_version(dist: str = "shellman") -> str: 60 | """Get version of the given distribution. 61 | 62 | Parameters: 63 | dist: A distribution name. 64 | 65 | Returns: 66 | A version number. 67 | """ 68 | try: 69 | return metadata.version(dist) 70 | except metadata.PackageNotFoundError: 71 | return "0.0.0" 72 | 73 | 74 | def _get_debug_info() -> _Environment: 75 | """Get debug/environment information. 76 | 77 | Returns: 78 | Environment information. 79 | """ 80 | py_name, py_version = _interpreter_name_version() 81 | packages = ["shellman"] 82 | variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("SHELLMAN")]] 83 | return _Environment( 84 | interpreter_name=py_name, 85 | interpreter_version=py_version, 86 | interpreter_path=sys.executable, 87 | platform=platform.platform(), 88 | variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))], 89 | packages=[_Package(pkg, _get_version(pkg)) for pkg in packages], 90 | ) 91 | 92 | 93 | def _print_debug_info() -> None: 94 | """Print debug/environment information.""" 95 | info = _get_debug_info() 96 | print(f"- __System__: {info.platform}") 97 | print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") 98 | print("- __Environment variables__:") 99 | for var in info.variables: 100 | print(f" - `{var.name}`: `{var.value}`") 101 | print("- __Installed packages__:") 102 | for pkg in info.packages: 103 | print(f" - `{pkg.name}` v{pkg.version}") 104 | 105 | 106 | if __name__ == "__main__": 107 | _print_debug_info() 108 | -------------------------------------------------------------------------------- /src/shellman/_internal/reader.py: -------------------------------------------------------------------------------- 1 | # Module to read a file/stream and pre-process the documentation lines. 2 | # 3 | # Algorithm is as follows: 4 | # 5 | # 1. preprocess_stream: yield documentation lines. 6 | # 2. preprocess_lines: group documentation lines as blocks of documentation. 7 | # 3. process_blocks: tidy blocks by tag in a dictionary. 8 | 9 | 10 | from __future__ import annotations 11 | 12 | import logging 13 | import os 14 | import re 15 | from collections import defaultdict 16 | from typing import TYPE_CHECKING 17 | 18 | from shellman._internal.tags import TAGS, Tag 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Iterable, Iterator, Sequence 22 | 23 | _logger = logging.getLogger(__name__) 24 | 25 | tag_value_regex = re.compile(r"^\s*[\\@]([_a-zA-Z][\w-]*)\s+(.+)$") 26 | """Regex to match a tag and its value.""" 27 | tag_no_value_regex = re.compile(r"^\s*[\\@]([_a-zA-Z][\w-]*)\s*$") 28 | """Regex to match a tag without a value.""" 29 | 30 | 31 | class DocType: 32 | """Enumeration of the possible types of documentation.""" 33 | 34 | TAG = "T" 35 | """A tag.""" 36 | 37 | TAG_VALUE = "TV" 38 | """A tag its value.""" 39 | 40 | VALUE = "V" 41 | """A value.""" 42 | 43 | INVALID = "I" 44 | """Invalid type.""" 45 | 46 | 47 | class DocLine: 48 | """A documentation line.""" 49 | 50 | def __init__(self, path: str, lineno: int, tag: str | None, value: str) -> None: 51 | """Initialize the doc line. 52 | 53 | Parameters: 54 | path: The origin file path. 55 | lineno: The line number in the file. 56 | tag: The line's tag, if any. 57 | value: The line's value. 58 | """ 59 | self.path = path 60 | """The origin file path.""" 61 | self.lineno = lineno 62 | """The line number in the file.""" 63 | self.tag = tag or "" 64 | """The line's tag.""" 65 | self.value = value 66 | """The line's value.""" 67 | 68 | def __str__(self) -> str: 69 | doc_type = self.doc_type 70 | if doc_type == DocType.TAG_VALUE: 71 | s = f'{self.tag}, "{self.value}"' 72 | elif doc_type == DocType.TAG: 73 | s = self.tag 74 | elif doc_type == DocType.VALUE: 75 | s = f'"{self.value}"' 76 | else: 77 | s = "invalid" 78 | return f"{self.path}:{self.lineno}: {doc_type}: {s}" 79 | 80 | @property 81 | def doc_type(self) -> str: 82 | """The line's doc type.""" 83 | if self.tag: 84 | if self.value: 85 | return DocType.TAG_VALUE 86 | return DocType.TAG 87 | if self.value is not None: 88 | return DocType.VALUE 89 | return DocType.INVALID 90 | 91 | 92 | class DocBlock: 93 | """A documentation block.""" 94 | 95 | def __init__(self, lines: list[DocLine] | None = None) -> None: 96 | """Initialize the doc block. 97 | 98 | Parameters: 99 | lines: The block's doc lines. 100 | """ 101 | self.lines = lines if lines is not None else [] 102 | """The block's doc lines.""" 103 | 104 | def __bool__(self) -> bool: 105 | """True if the block has lines.""" 106 | return bool(self.lines) 107 | 108 | def __str__(self) -> str: 109 | return "\n".join([str(line) for line in self.lines]) 110 | 111 | def append(self, line: DocLine) -> None: 112 | """Append a line to the block. 113 | 114 | Parameters: 115 | line: The doc line to append. 116 | """ 117 | self.lines.append(line) 118 | 119 | @property 120 | def doc_type(self) -> str: 121 | """The block type.""" 122 | return self.lines[0].doc_type 123 | 124 | @property 125 | def first_line(self) -> DocLine: 126 | """The block's first doc line.""" 127 | return self.lines[0] 128 | 129 | @property 130 | def lines_number(self) -> int: 131 | """The number of lines in the block.""" 132 | return len(self.lines) 133 | 134 | @property 135 | def path(self) -> str: 136 | """The block's origin file path.""" 137 | return self.first_line.path 138 | 139 | @property 140 | def lineno(self) -> int: 141 | """The block's first line number.""" 142 | return self.first_line.lineno 143 | 144 | @property 145 | def tag(self) -> str: 146 | """The block's tag.""" 147 | if self.lines: 148 | return self.first_line.tag 149 | return "" 150 | 151 | @property 152 | def value(self) -> str: 153 | """The block's first line.""" 154 | return self.first_line.value 155 | 156 | @property 157 | def values(self) -> list[str]: 158 | """The block's lines.""" 159 | return [line.value for line in self.lines] 160 | 161 | 162 | class DocStream: 163 | """A stream of shell code or documentation.""" 164 | 165 | def __init__(self, stream: Iterable[str], filename: str = "") -> None: 166 | """Initialize the documentation file. 167 | 168 | Parameters: 169 | stream: A text stream. 170 | filename: An optional file name. 171 | """ 172 | self.filepath = None 173 | """The file path.""" 174 | self.filename = filename 175 | """The file name.""" 176 | self.sections = _process_blocks(_preprocess_lines(_preprocess_stream(stream))) 177 | """The documentation sections.""" 178 | 179 | 180 | class DocFile: 181 | """A shell script or documentation file.""" 182 | 183 | def __init__(self, path: str) -> None: 184 | """Initialize the documentation file. 185 | 186 | Parameters: 187 | path: The path to the file. 188 | """ 189 | self.filepath = path 190 | """The file path.""" 191 | self.filename = os.path.basename(path) 192 | """The file name.""" 193 | self.sections: dict[str, list[Tag]] = {} 194 | """The documentation sections.""" 195 | 196 | with open(path, encoding="utf-8") as stream: 197 | try: 198 | self.sections = _process_blocks(_preprocess_lines(_preprocess_stream(stream))) 199 | except UnicodeDecodeError: 200 | _logger.error(f"Cannot read file {path}") # noqa: TRY400 201 | self.sections = {} 202 | 203 | 204 | def _preprocess_stream(stream: Iterable[str]) -> Iterator[tuple[str, int, str]]: 205 | name = getattr(stream, "name", "") 206 | for lineno, line in enumerate(stream, 1): 207 | line = line.lstrip(" \t").rstrip("\n") # noqa: PLW2901 208 | if line.startswith("##"): 209 | yield name, lineno, line 210 | 211 | 212 | def _preprocess_lines(lines: Iterable[tuple[str, int, str]]) -> Iterator[DocBlock]: 213 | current_block = DocBlock() 214 | for path, lineno, line in lines: 215 | line = line[3:] # noqa: PLW2901 216 | res = tag_value_regex.search(line) 217 | if res: 218 | tag, value = res.groups() 219 | if current_block and not tag.startswith(current_block.tag + "-"): 220 | yield current_block 221 | current_block = DocBlock() 222 | current_block.append(DocLine(path, lineno, tag, value)) 223 | else: 224 | res = tag_no_value_regex.search(line) 225 | if res: 226 | tag = res.groups()[0] 227 | if current_block and not tag.startswith(current_block.tag + "-"): 228 | yield current_block 229 | current_block = DocBlock() 230 | current_block.append(DocLine(path, lineno, tag, "")) 231 | else: 232 | current_block.append(DocLine(path, lineno, None, line)) 233 | if current_block: 234 | yield current_block 235 | 236 | 237 | def _process_blocks(blocks: Iterable[DocBlock]) -> dict[str, list[Tag]]: 238 | sections: dict[str, list[Tag]] = defaultdict(list) 239 | for block in blocks: 240 | tag_class = TAGS.get(block.tag, TAGS[None]) 241 | sections[block.tag].append(tag_class.from_lines(block.lines)) 242 | return dict(sections) 243 | 244 | 245 | def _merge(docs: Sequence[DocStream | DocFile], filename: str) -> DocStream: 246 | final_doc = DocStream(stream=[], filename=filename) 247 | for doc in docs: 248 | for section, values in doc.sections.items(): 249 | if section not in final_doc.sections: 250 | final_doc.sections[section] = [] 251 | final_doc.sections[section].extend(values) 252 | return final_doc 253 | -------------------------------------------------------------------------------- /src/shellman/_internal/tags.py: -------------------------------------------------------------------------------- 1 | # All tags are defined here. 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import sys 7 | import warnings 8 | from dataclasses import dataclass 9 | from functools import cached_property 10 | from typing import TYPE_CHECKING, Any, ClassVar 11 | 12 | # YORE: EOL 3.10: Replace block with line 4. 13 | if sys.version_info < (3, 11): 14 | from typing_extensions import Self 15 | else: 16 | from typing import Self 17 | 18 | if TYPE_CHECKING: 19 | from collections.abc import Sequence 20 | 21 | from shellman._internal.reader import DocLine 22 | 23 | 24 | # YORE: Bump 2: Remove block. 25 | def __getattr__(name: str) -> Any: 26 | if name == "NameDescTag": 27 | warnings.warn("NameDescTag is deprecated, use ValueDescTag instead.", DeprecationWarning, stacklevel=2) 28 | return ValueDescTag 29 | raise AttributeError(f"module {__name__} has no attribute {name}") 30 | 31 | 32 | class Tag: 33 | """Base class for tags.""" 34 | 35 | @classmethod 36 | def from_lines(cls, lines: Sequence[DocLine]) -> Tag: 37 | """Parse a sequence of lines into a tag instance. 38 | 39 | Parameters: 40 | lines: The sequence of lines to parse. 41 | 42 | Returns: 43 | A tag instance. 44 | """ 45 | raise NotImplementedError 46 | 47 | 48 | @dataclass 49 | class TextTag(Tag): 50 | """A simple tag holding text only.""" 51 | 52 | text: str 53 | """The tag's text.""" 54 | 55 | @classmethod 56 | def from_lines(cls, lines: Sequence[DocLine]) -> TextTag: 57 | return cls(text="\n".join(line.value for line in lines)) 58 | 59 | 60 | @dataclass 61 | class ValueDescTag(Tag): 62 | """A tag holding a value and a description.""" 63 | 64 | tag: ClassVar[str] 65 | """The tag name.""" 66 | 67 | value_field_name: ClassVar[str] = "name" 68 | """The name of the field containing the value.""" 69 | 70 | description_field_name: ClassVar[str] = "description" 71 | """The name of the field containing the description.""" 72 | 73 | @classmethod 74 | def from_lines(cls, lines: Sequence[DocLine]) -> Self: 75 | value, description = "", [] 76 | for line in lines: 77 | if line.tag == cls.tag: 78 | split = line.value.split(" ", 1) 79 | if len(split) > 1: 80 | value = split[0] 81 | description.append(split[1]) 82 | else: 83 | value = split[0] 84 | else: 85 | description.append(line.value) 86 | return cls(**{cls.value_field_name: value, cls.description_field_name: "\n".join(description)}) 87 | 88 | 89 | @dataclass 90 | class AuthorTag(TextTag): 91 | """A tag representing an author.""" 92 | 93 | 94 | @dataclass 95 | class BugTag(TextTag): 96 | """A tag representing a bug note.""" 97 | 98 | 99 | @dataclass 100 | class BriefTag(TextTag): 101 | """A tag representing a summary.""" 102 | 103 | 104 | @dataclass 105 | class CaveatTag(TextTag): 106 | """A tag representing caveats.""" 107 | 108 | 109 | @dataclass 110 | class CopyrightTag(TextTag): 111 | """A tag representing copyright information.""" 112 | 113 | 114 | @dataclass 115 | class DateTag(TextTag): 116 | """A tag representing a date.""" 117 | 118 | 119 | @dataclass 120 | class DescTag(TextTag): 121 | """A tag representing a description.""" 122 | 123 | 124 | @dataclass 125 | class EnvTag(ValueDescTag): 126 | """A tag representing an environment variable used by the script.""" 127 | 128 | tag: ClassVar[str] = "env" 129 | 130 | name: str 131 | """The environment variable name.""" 132 | description: str 133 | """The environment variable description.""" 134 | 135 | 136 | @dataclass 137 | class ErrorTag(TextTag): 138 | """A tag representing a known error.""" 139 | 140 | 141 | @dataclass 142 | class ExampleTag(Tag): 143 | """A tag representing a code/shell example.""" 144 | 145 | brief: str 146 | """The example's summary.""" 147 | code: str 148 | """The example's code.""" 149 | code_lang: str 150 | """The example's language.""" 151 | description: str 152 | """The example's description.""" 153 | 154 | @classmethod 155 | def from_lines(cls, lines: Sequence[DocLine]) -> ExampleTag: 156 | brief, code, description = [], [], [] 157 | code_lang = "" 158 | current = None 159 | for line in lines: 160 | if line.tag == "example": 161 | if line.value: 162 | brief.append(line.value) 163 | current = "brief" 164 | elif line.tag == "example-code": 165 | if line.value: 166 | code_lang = line.value 167 | current = "code" 168 | elif line.tag == "example-description": 169 | if line.value: 170 | description.append(line.value) 171 | current = "description" 172 | elif current == "brief": 173 | brief.append(line.value) 174 | elif current == "code": 175 | code.append(line.value) 176 | elif current == "description": 177 | description.append(line.value) 178 | 179 | return ExampleTag( 180 | brief="\n".join(brief), 181 | code="\n".join(code), 182 | code_lang=code_lang, 183 | description="\n".join(description), 184 | ) 185 | 186 | 187 | @dataclass 188 | class ExitTag(ValueDescTag): 189 | """A tag representing an exit code.""" 190 | 191 | tag: ClassVar[str] = "exit" 192 | value_field_name: ClassVar[str] = "code" 193 | 194 | code: str 195 | """The exit code value.""" 196 | description: str 197 | """The exit code description.""" 198 | 199 | 200 | @dataclass 201 | class FileTag(ValueDescTag): 202 | """A tag representing a file used by a script.""" 203 | 204 | tag: ClassVar[str] = "file" 205 | 206 | name: str 207 | """The file name/path.""" 208 | description: str 209 | """The file description.""" 210 | 211 | 212 | @dataclass 213 | class FunctionTag(Tag): 214 | """A tag representing a shell function.""" 215 | 216 | prototype: str 217 | """The function's prototype.""" 218 | brief: str 219 | """The function's summary.""" 220 | description: str 221 | """The function's description.""" 222 | arguments: Sequence[str] 223 | """The function's arguments.""" 224 | preconditions: Sequence[str] 225 | """The function's preconditions.""" 226 | return_codes: Sequence[str] 227 | """The function's return codes.""" 228 | seealso: Sequence[str] 229 | """The function's "see also" information.""" 230 | stderr: Sequence[str] 231 | """The function's standard error.""" 232 | stdin: Sequence[str] 233 | """The function's standard input.""" 234 | stdout: Sequence[str] 235 | """The function's standard output.""" 236 | 237 | @classmethod 238 | def from_lines(cls, lines: Sequence[DocLine]) -> FunctionTag: 239 | brief = "" 240 | prototype = "" 241 | description = [] 242 | arguments = [] 243 | return_codes = [] 244 | preconditions = [] 245 | seealso = [] 246 | stderr = [] 247 | stdin = [] 248 | stdout = [] 249 | for line in lines: 250 | if line.tag == "function": 251 | prototype = line.value 252 | elif line.tag == "function-brief": 253 | brief = line.value 254 | elif line.tag == "function-description": 255 | description.append(line.value) 256 | elif line.tag == "function-argument": 257 | arguments.append(line.value) 258 | elif line.tag == "function-precondition": 259 | preconditions.append(line.value) 260 | elif line.tag == "function-return": 261 | return_codes.append(line.value) 262 | elif line.tag == "function-seealso": 263 | seealso.append(line.value) 264 | elif line.tag == "function-stderr": 265 | stderr.append(line.value) 266 | elif line.tag == "function-stdin": 267 | stdin.append(line.value) 268 | elif line.tag == "function-stdout": 269 | stdout.append(line.value) 270 | else: 271 | description.append(line.value) 272 | 273 | return FunctionTag( 274 | prototype=prototype, 275 | brief=brief, 276 | description="\n".join(description), 277 | arguments=arguments, 278 | preconditions=preconditions, 279 | return_codes=return_codes, 280 | seealso=seealso, 281 | stderr=stderr, 282 | stdin=stdin, 283 | stdout=stdout, 284 | ) 285 | 286 | 287 | @dataclass 288 | class HistoryTag(TextTag): 289 | """A tag representing a script's history.""" 290 | 291 | 292 | @dataclass 293 | class LicenseTag(TextTag): 294 | """A tag representing a license.""" 295 | 296 | 297 | @dataclass 298 | class NoteTag(TextTag): 299 | """A tag representing a note.""" 300 | 301 | 302 | @dataclass 303 | class OptionTag(Tag): 304 | """A tag representing a command-line option.""" 305 | 306 | short: str 307 | """The option short flag.""" 308 | long: str 309 | """The option long flag.""" 310 | positional: str 311 | """The option positional arguments.""" 312 | default: str 313 | """The option default value.""" 314 | group: str 315 | """The option group.""" 316 | description: str 317 | """The option description.""" 318 | 319 | @cached_property 320 | def signature(self) -> str: 321 | """The signature of the option.""" 322 | sign = "" 323 | if self.short: 324 | sign = self.short 325 | if self.long: 326 | sign += ", " 327 | elif self.positional: 328 | sign += " " 329 | if self.long: 330 | if not self.short: 331 | sign += " " 332 | sign += self.long + " " 333 | if self.positional: 334 | sign += self.positional 335 | return sign 336 | 337 | @classmethod 338 | def from_lines(cls, lines: Sequence[DocLine]) -> OptionTag: 339 | short, long, positional, default, group = "", "", "", "", "" 340 | description = [] 341 | for line in lines: 342 | if line.tag == "option": 343 | search = re.search( 344 | r"^(?P-\w)?(?:, )?(?P--[\w-]+)? ?(?P.+)?", 345 | line.value, 346 | ) 347 | if search: 348 | short, long, positional = search.groups(default="") 349 | else: 350 | positional = line.value 351 | elif line.tag == "option-default": 352 | default = line.value 353 | elif line.tag == "option-group": 354 | group = line.value 355 | else: 356 | description.append(line.value) 357 | return OptionTag( 358 | short=short, 359 | long=long, 360 | positional=positional, 361 | default=default, 362 | group=group, 363 | description="\n".join(description), 364 | ) 365 | 366 | 367 | @dataclass 368 | class SeealsoTag(TextTag): 369 | """A tag representing "See Also" information.""" 370 | 371 | 372 | @dataclass 373 | class StderrTag(TextTag): 374 | """A tag representing the standard error of a script/function.""" 375 | 376 | 377 | @dataclass 378 | class StdinTag(TextTag): 379 | """A tag representing the standard input of a script/function.""" 380 | 381 | 382 | @dataclass 383 | class StdoutTag(TextTag): 384 | """A tag representing the standard output of a script/function.""" 385 | 386 | 387 | @dataclass 388 | class UsageTag(Tag): 389 | """A tag representing the command-line usage of a script.""" 390 | 391 | program: str 392 | """The program name.""" 393 | command: str 394 | """The command-line usage.""" 395 | 396 | @classmethod 397 | def from_lines(cls, lines: Sequence[DocLine]) -> UsageTag: 398 | program, command = "", "" 399 | split = lines[0].value.split(" ", 1) 400 | if len(split) > 1: 401 | program, command = split 402 | else: 403 | program = split[0] 404 | if len(lines) > 1: 405 | command = command + "\n" + "\n".join(line.value for line in lines[1:]) 406 | return UsageTag(program=program, command=command) 407 | 408 | 409 | @dataclass 410 | class VersionTag(TextTag): 411 | """A tag representing a version.""" 412 | 413 | 414 | TAGS: dict[str | None, type[Tag]] = { 415 | None: TextTag, 416 | "author": AuthorTag, 417 | "bug": BugTag, 418 | "brief": BriefTag, 419 | "caveat": CaveatTag, 420 | "copyright": CopyrightTag, 421 | "date": DateTag, 422 | "desc": DescTag, 423 | "env": EnvTag, 424 | "error": ErrorTag, 425 | "example": ExampleTag, 426 | "exit": ExitTag, 427 | "file": FileTag, 428 | "function": FunctionTag, 429 | "history": HistoryTag, 430 | "license": LicenseTag, 431 | "note": NoteTag, 432 | "option": OptionTag, 433 | "seealso": SeealsoTag, 434 | "stderr": StderrTag, 435 | "stdin": StdinTag, 436 | "stdout": StdoutTag, 437 | "usage": UsageTag, 438 | "version": VersionTag, 439 | } 440 | """A dictionary of tag names and their corresponding tag classes.""" 441 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/__init__.py: -------------------------------------------------------------------------------- 1 | # This module contains our definitions of templates. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from copy import deepcopy 8 | from importlib.metadata import entry_points 9 | from typing import Any 10 | 11 | from jinja2 import Environment, FileSystemLoader 12 | from jinja2.exceptions import TemplateNotFound 13 | 14 | from shellman._internal.templates.filters import FILTERS 15 | 16 | if sys.version_info < (3, 10): 17 | from importlib_metadata import entry_points # type: ignore[assignment] 18 | else: 19 | from importlib.metadata import entry_points 20 | 21 | 22 | def _get_builtin_path() -> str: 23 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") 24 | 25 | 26 | def _get_env(path: str) -> Environment: 27 | return Environment( # noqa: S701 28 | loader=FileSystemLoader(path), 29 | trim_blocks=True, 30 | lstrip_blocks=True, 31 | keep_trailing_newline=True, 32 | auto_reload=False, 33 | ) 34 | 35 | 36 | builtin_env = _get_env(_get_builtin_path()) 37 | """The built-in Jinja environment.""" 38 | 39 | 40 | class Template: 41 | """Shellman templates.""" 42 | 43 | def __init__( 44 | self, 45 | env_or_directory: str | Environment, 46 | base_template: str, 47 | context: dict[str, Any] | None = None, 48 | filters: dict[str, Any] | None = None, 49 | ): 50 | """Initialize the template. 51 | 52 | Parameters: 53 | env_or_directory: Jinja environment or directory to load environment from. 54 | base_template: The template file to use. 55 | context: Base context to render with. 56 | filters: Base filters to add to the environment. 57 | """ 58 | self.env: Environment 59 | """The Jinja environment.""" 60 | 61 | if isinstance(env_or_directory, Environment): 62 | self.env = env_or_directory 63 | elif isinstance(env_or_directory, str): 64 | self.env = _get_env(env_or_directory) 65 | else: 66 | raise TypeError(env_or_directory) 67 | 68 | if filters is None: 69 | filters = {} 70 | 71 | self.env.filters.update(FILTERS) 72 | self.env.filters.update(filters) 73 | 74 | self.base_template = base_template 75 | """The base template file.""" 76 | self.context = context or {} 77 | """The base context.""" 78 | self.__template: Template = None # type: ignore[assignment] 79 | 80 | @property 81 | def template(self) -> Template: 82 | """The corresponding Jinja template.""" 83 | if self.__template is None: 84 | self.__template = self.env.get_template(self.base_template) 85 | return self.__template 86 | 87 | def render(self, **kwargs: Any) -> str: 88 | """Render the template. 89 | 90 | Parameters: 91 | **kwargs: Keyword arguments passed to Jinja's render method. 92 | 93 | 94 | Returns: 95 | The rendered text. 96 | """ 97 | context = deepcopy(self.context) 98 | context.update(kwargs) 99 | return self.template.render(**context).rstrip("\n") 100 | 101 | 102 | def _get_custom_template(base_template_path: str) -> Template: 103 | directory, base_template = os.path.split(base_template_path) 104 | try: 105 | return Template(directory or ".", base_template) 106 | except TemplateNotFound as error: 107 | raise FileNotFoundError(base_template_path) from error 108 | 109 | 110 | def _load_plugin_templates() -> None: 111 | for entry_point in entry_points(group="shellman"): # type: ignore[call-arg] 112 | obj = entry_point.load() # type: ignore[attr-defined] 113 | if isinstance(obj, Template): 114 | templates[entry_point.name] = obj # type: ignore[attr-defined] 115 | elif isinstance(obj, dict): 116 | for name, template in obj.items(): 117 | if isinstance(template, Template): 118 | templates[name] = template 119 | 120 | 121 | def _names() -> list[str]: 122 | return sorted(templates.keys()) 123 | 124 | 125 | def _parser_choices() -> tuple[str]: 126 | class TemplateChoice(tuple): 127 | def __contains__(self, item: str) -> bool: # type: ignore[override] 128 | return super().__contains__(item) or item.startswith("path:") 129 | 130 | return TemplateChoice(_names()) # type: ignore[return-value] 131 | 132 | 133 | helptext = Template( 134 | builtin_env, 135 | "helptext", 136 | context={"indent": 2, "option_padding": 22}, 137 | ) 138 | """Template for help text.""" 139 | manpage = Template(builtin_env, "manpage.groff", context={"indent": 4}) 140 | """Template for manpages.""" 141 | manpage_md = Template(builtin_env, "manpage.md") 142 | """Template for manpages in Markdown format.""" 143 | wikipage = Template(builtin_env, "wikipage.md") 144 | """Template for wiki pages.""" 145 | usagetext = Template(builtin_env, "usagetext") 146 | """Template for usage text.""" 147 | 148 | templates = { 149 | "usagetext": usagetext, 150 | "helptext": helptext, 151 | "manpage": manpage, 152 | "manpage.groff": manpage, 153 | "manpage.1": manpage, 154 | "manpage.3": manpage, 155 | "manpage.md": manpage_md, 156 | "manpage.markdown": manpage_md, 157 | "wikipage": wikipage, 158 | "wikipage.md": wikipage, 159 | "wikipage.markdown": wikipage, 160 | } 161 | """The available templates.""" 162 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/helptext: -------------------------------------------------------------------------------- 1 | {% if indent is string %}{% set indent=indent|int %}{% endif %} 2 | {% set indent_str=" "*indent %} 3 | {% if option_padding is string %}{% set option_padding=option_padding|int %}{% endif %} 4 | {% if shellman.doc.usage %} 5 | Usage: {{ shellman.doc.usage[0].program }} {{ shellman.doc.usage[0].command|smartwrap(8 + shellman.doc.usage[0].program|length, indentfirst=False) }} 6 | {% for usage in shellman.doc.usage[1:] %} 7 | {{ usage.program }} {{ usage.command|smartwrap(8 + usage.program|length, indentfirst=False) }} 8 | {% endfor %} 9 | 10 | {% endif %} 11 | {% if shellman.doc.desc %} 12 | {% for desc in shellman.doc.desc %} 13 | {{ desc.text|smartwrap(0) }} 14 | {% if not loop.last %}{{ "\n" }}{% endif %} 15 | {% endfor %} 16 | 17 | {% endif %} 18 | {% if shellman.doc.option %} 19 | Options: 20 | {% for opt_group, opt_list in shellman.doc.option|groupby('group', sort=False) %} 21 | {% if opt_group %} 22 | {{ ' ' * (indent / 2)|int }}{{ opt_group }}: 23 | {% endif %} 24 | {% for option in opt_list %} 25 | {{ indent_str }}{{ "{sign:{padding}}"|format(sign=option.signature, padding=option_padding-1) }} {% if option.signature|length > option_padding-2 %}{{ '\n' + option.description|smartwrap(indent + option_padding) + '\n' }}{% else %}{{ option.description|smartwrap(indent + option_padding, indentfirst=False) + '\n' }}{% endif %} 26 | {% endfor %} 27 | {% if not loop.last %}{{ '\n' }}{% endif %} 28 | {% endfor %} 29 | 30 | {% endif %} 31 | {% if shellman.doc.env %} 32 | Environment Variables: 33 | {% for env in shellman.doc.env %} 34 | {{ indent_str }}{{ env.name }} 35 | {{ env.description|smartwrap(indent*2) }} 36 | {% endfor %} 37 | 38 | {% endif %} 39 | {% if shellman.doc.file %} 40 | Files: 41 | {% for file in shellman.doc.file %} 42 | {{ indent_str }}{{ file.name }} 43 | {{ file.description|smartwrap(indent*2) }} 44 | {% endfor %} 45 | 46 | {% endif %} 47 | {% if shellman.doc.exit %} 48 | Exit Status: 49 | {% for exit in shellman.doc.exit %} 50 | {{ indent_str }}{{ exit.code }} 51 | {{ exit.description|smartwrap(indent*2) }} 52 | {% endfor %} 53 | 54 | {% endif %} 55 | {% if shellman.doc.stdin %} 56 | Standard Input: 57 | {% for stdin in shellman.doc.stdin %} 58 | {{ stdin.text|smartwrap(indent) }} 59 | {% if not loop.last %}{{ "\n" }}{% endif %} 60 | {% endfor %} 61 | 62 | {% endif %} 63 | {% if shellman.doc.stdout %} 64 | Standard Output: 65 | {% for stdout in shellman.doc.stdout %} 66 | {{ stdout.text|smartwrap(indent) }} 67 | {% if not loop.last %}{{ "\n" }}{% endif %} 68 | {% endfor %} 69 | 70 | {% endif %} 71 | {% if shellman.doc.stderr %} 72 | Standard Error: 73 | {% for stderr in shellman.doc.stderr %} 74 | {{ stderr.text|smartwrap(indent) }} 75 | {% if not loop.last %}{{ "\n" }}{% endif %} 76 | {% endfor %} 77 | 78 | {% endif %} 79 | {% if shellman.doc.function %} 80 | Functions: 81 | {% for function in shellman.doc.function %} 82 | {% include "helptext_function" with context %} 83 | {% endfor %} 84 | 85 | {% endif %} 86 | {% if shellman.doc.example %} 87 | Examples: 88 | {% for example in shellman.doc.example %} 89 | {{ indent_str }}{{ example.brief }} 90 | {% if example.code %}{{ "\n" + example.code|smartwrap(indent*2) + "\n\n" }}{% endif %} 91 | {% if example.description %} 92 | {{ example.description|smartwrap(indent*2) }} 93 | {% endif %} 94 | {% if not loop.last %}{{ "\n" }}{% endif %} 95 | {% endfor %} 96 | 97 | {% endif %} 98 | {# 99 | {% if shellman.doc.error %} 100 | Errors: 101 | {% for error in shellman.doc.error %} 102 | {{ error.text|smartwrap(indent) }} 103 | {% if not loop.last %}{{ "\n" }}{% endif %} 104 | {% endfor %} 105 | 106 | {% endif %} 107 | {% if shellman.doc.bug %} 108 | Bugs: 109 | {% for bug in shellman.doc.bug %} 110 | {{ bug.text|smartwrap(indent) }} 111 | {% if not loop.last %}{{ "\n" }}{% endif %} 112 | {% endfor %} 113 | 114 | {% endif %} 115 | {% if shellman.doc.caveat %} 116 | Caveats: 117 | {% for caveat in shellman.doc.caveat %} 118 | {{ caveat.text|smartwrap(indent) }} 119 | {% if not loop.last %}{{ "\n" }}{% endif %} 120 | {% endfor %} 121 | 122 | {% endif %} 123 | {% if shellman.doc.author %} 124 | Authors: 125 | {% for author in shellman.doc.author %} 126 | {{ indent_str }}{{ author.text }} 127 | {% if not loop.last %}{{ "\n" }}{% endif %} 128 | {% endfor %} 129 | 130 | {% endif %} 131 | {% if shellman.doc.copyright %} 132 | Copyright: 133 | {% for copyright in shellman.doc.copyright %} 134 | {{ copyright.text|smartwrap(indent) }} 135 | {% if not loop.last %}{{ "\n" }}{% endif %} 136 | {% endfor %} 137 | 138 | {% endif %} 139 | {% if shellman.doc.license %} 140 | License: 141 | {% for license in shellman.doc.license %} 142 | {{ license.text|smartwrap(indent) }} 143 | {% if not loop.last %}{{ "\n" }}{% endif %} 144 | {% endfor %} 145 | 146 | {% endif %} 147 | {% if shellman.doc.history %} 148 | History: 149 | {% for history in shellman.doc.history %} 150 | {{ history.text|smartwrap(indent) }} 151 | {% if not loop.last %}{{ "\n" }}{% endif %} 152 | {% endfor %} 153 | 154 | {% endif %} 155 | {% if shellman.doc.note %} 156 | Notes: 157 | {% for note in shellman.doc.note %} 158 | {{ note.text|smartwrap(indent) }} 159 | {% if not loop.last %}{{ "\n" }}{% endif %} 160 | {% endfor %} 161 | 162 | {% endif %} 163 | {% if shellman.doc.seealso %} 164 | See Also: 165 | {% for seealso in shellman.doc.seealso %} 166 | {{ seealso.text|smartwrap(indent) }} 167 | {% if not loop.last %}{{ "\n" }}{% endif %} 168 | {% endfor %} 169 | {% endif %} 170 | #} 171 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/helptext_function: -------------------------------------------------------------------------------- 1 | {{ indent_str }}{{ function.prototype }} 2 | {{ indent_str * 2 }}{{ function.brief }} 3 | 4 | {% if function.description %} 5 | {{ function.description|smartwrap(indent*2) }} 6 | 7 | {% endif %} 8 | {% if function.arguments %} 9 | {{ indent_str * 2 }}Arguments: 10 | {% with longest = function.arguments|map('firstword')|map('length')|max %} 11 | {% for argument in function.arguments %} 12 | {{ indent_str * 3 }}{{ "{a:{w}}"|format(a=argument|firstword, w=longest) }} - {{ argument|body }} 13 | {% endfor %} 14 | {% endwith %} 15 | 16 | {% endif %} 17 | {% if function.return_codes %} 18 | {{ indent_str * 2 }}Return codes: 19 | {% for return_code in function.return_codes %} 20 | {{ indent_str * 3 }}{{ return_code|firstword }} - {{ return_code|body }} 21 | {% endfor %} 22 | 23 | {% endif %} 24 | {% if function.preconditions %} 25 | {{ indent_str * 2 }}Pre-conditions: 26 | {% for precondition in function.preconditions %} 27 | {{ indent_str * 3 }}{{ precondition }} 28 | {% endfor %} 29 | 30 | {% endif %} 31 | {% if function.seealso %} 32 | {{ indent_str * 2 }}See also: 33 | {% for seealso in function.seealso %} 34 | {{ indent_str * 3 }}{{ seealso }} 35 | {% endfor %} 36 | 37 | {% endif %} 38 | {% if function.stdin %} 39 | {{ indent_str * 2 }}Standard input: 40 | {% for stdin in function.stdin %} 41 | {{ indent_str * 3 }}{{ stdin }} 42 | {% endfor %} 43 | 44 | {% endif %} 45 | {% if function.stdout %} 46 | {{ indent_str * 2 }}Standard output: 47 | {% for stdout in function.stdout %} 48 | {{ indent_str * 3 }}{{ stdout }} 49 | {% endfor %} 50 | 51 | {% endif %} 52 | {% if function.stderr %} 53 | {{ indent_str * 2 }}Standard error: 54 | {% for stderr in function.stderr %} 55 | {{ indent_str * 3 }}{{ stderr }} 56 | {% endfor %} 57 | 58 | {% endif %} 59 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/manpage.groff: -------------------------------------------------------------------------------- 1 | {% if indent is string %}{% set indent=indent|int %}{% endif %} 2 | {% set indent_str=" "*indent %} 3 | .if n.ad l 4 | .nh 5 | 6 | .TH {{ shellman.filename }} 1 "{% if shellman.doc.version -%} 7 | Version {{ shellman.doc.version[0].text }} - {% endif -%} 8 | {% if shellman.doc.date %}{{ shellman.doc.date[0].text }}{% else %}{{ shellman.today }}{% endif %}" "shellman {{ shellman.version }}" "User Commands" 9 | 10 | {% if shellman.doc.brief %} 11 | .SH "NAME" 12 | {{ shellman.filename }} \- {{ shellman.doc.brief[0].text }} 13 | 14 | {% endif %} 15 | {% if shellman.doc.usage %} 16 | .SH "SYNOPSIS" 17 | {% for usage in shellman.doc.usage %} 18 | {{ usage.program|groffstrong }} {{ usage.command|groffauto }} 19 | {% if not loop.last %} 20 | .br 21 | {% endif %} 22 | {% endfor %} 23 | 24 | {% endif %} 25 | {% if shellman.doc.desc %} 26 | .SH "DESCRIPTION" 27 | {% for desc in shellman.doc.desc %} 28 | {{ desc.text }} 29 | {% if not loop.last %}{{ "\n" }}{% endif %} 30 | {% endfor %} 31 | 32 | {% endif %} 33 | {% if shellman.doc.option %} 34 | .SH "OPTIONS" 35 | {% for opt_group, opt_list in shellman.doc.option|groupby('group', sort=False) %} 36 | {% if opt_group %} 37 | .SS "{{ opt_group }}" 38 | {% endif %} 39 | {% for option in opt_list %} 40 | {% with sign = option.signature.lstrip() %} 41 | {% if sign|length > indent-2 %} 42 | .IP "{{ sign|groffauto }}" {{ indent }} 43 | {{ option.description + '\n' }} 44 | {% else %} 45 | .TP 46 | {{ "{sign:{padding}}"|format(sign=sign, padding=indent-1)|groffauto }} {{ option.description }} 47 | {% endif %} 48 | {% endwith %} 49 | {% endfor %} 50 | {% endfor %} 51 | 52 | {% endif %} 53 | {% if shellman.doc.env %} 54 | .SH "ENVIRONMENT VARIABLES" 55 | {% for env in shellman.doc.env %} 56 | .TP 57 | .B {{ env.name }} 58 | {{ env.description }} 59 | {% endfor %} 60 | 61 | {% endif %} 62 | {% if shellman.doc.file %} 63 | .SH "FILES" 64 | {% for file in shellman.doc.file %} 65 | .TP 66 | .I {{ file.name }} 67 | {{ file.description }} 68 | {% endfor %} 69 | 70 | {% endif %} 71 | {% if shellman.doc.exit %} 72 | .SH "EXIT STATUS" 73 | {% for exit in shellman.doc.exit %} 74 | .TP 75 | .B {{ exit.code }} 76 | {{ exit.description }} 77 | {% endfor %} 78 | 79 | {% endif %} 80 | {% if shellman.doc.stdin %} 81 | .SH "STANDARD INPUT" 82 | {% for stdin in shellman.doc.stdin %} 83 | {{ stdin.text }} 84 | {% if not loop.last %}{{ "\n" }}{% endif %} 85 | {% endfor %} 86 | 87 | {% endif %} 88 | {% if shellman.doc.stdout %} 89 | .SH "STANDARD OUTPUT" 90 | {% for stdout in shellman.doc.stdout %} 91 | {{ stdout.text }} 92 | {% if not loop.last %}{{ "\n" }}{% endif %} 93 | {% endfor %} 94 | 95 | {% endif %} 96 | {% if shellman.doc.stderr %} 97 | .SH "STANDARD ERROR" 98 | {% for stderr in shellman.doc.stderr %} 99 | {{ stderr.text }} 100 | {% if not loop.last %}{{ "\n" }}{% endif %} 101 | {% endfor %} 102 | 103 | {% endif %} 104 | {% if shellman.doc.function %} 105 | .SH "FUNCTIONS" 106 | {% for function in shellman.doc.function %} 107 | {% include "manpage_function.groff" with context %} 108 | {% endfor %} 109 | 110 | {% endif %} 111 | {% if shellman.doc.example %} 112 | .SH "EXAMPLES" 113 | {% for example in shellman.doc.example %} 114 | .IP "{{ example.brief|groffstrong }}" {{ indent }} 115 | {% if example.code %}{{ "\n" + example.code + "\n\n" }}{% endif %} 116 | {% if example.description %}{{ example.description + "\n" }}{% endif %} 117 | {% if not loop.last %}{{ "\n" }}{% endif %} 118 | {% endfor %} 119 | 120 | {% endif %} 121 | {% if shellman.doc.error %} 122 | .SH "ERRORS" 123 | {% for error in shellman.doc.error %} 124 | {{ error.text }} 125 | {% if not loop.last %}{{ "\n" }}{% endif %} 126 | {% endfor %} 127 | 128 | {% endif %} 129 | {% if shellman.doc.bug %} 130 | .SH "BUGS" 131 | {% for bug in shellman.doc.bug %} 132 | {{ bug.text }} 133 | {% if not loop.last %}{{ "\n" }}{% endif %} 134 | {% endfor %} 135 | 136 | {% endif %} 137 | {% if shellman.doc.caveat %} 138 | .SH "CAVEATS" 139 | {% for caveat in shellman.doc.caveat %} 140 | {{ caveat.text }} 141 | {% if not loop.last %}{{ "\n" }}{% endif %} 142 | {% endfor %} 143 | 144 | {% endif %} 145 | {% if shellman.doc.author %} 146 | .SH "AUTHORS" 147 | {% for author in shellman.doc.author %} 148 | {{ author.text }} 149 | {% if not loop.last %}{{ "\n" }}{% endif %} 150 | {% endfor %} 151 | 152 | {% endif %} 153 | {% if shellman.doc.copyright %} 154 | .SH "COPYRIGHT" 155 | {% for copyright in shellman.doc.copyright %} 156 | {{ copyright.text }} 157 | {% if not loop.last %}{{ "\n" }}{% endif %} 158 | {% endfor %} 159 | 160 | {% endif %} 161 | {% if shellman.doc.license %} 162 | .SH "LICENSE" 163 | {% for license in shellman.doc.license %} 164 | {{ license.text }} 165 | {% if not loop.last %}{{ "\n" }}{% endif %} 166 | {% endfor %} 167 | 168 | {% endif %} 169 | {% if shellman.doc.history %} 170 | .SH "HISTORY" 171 | {% for history in shellman.doc.history %} 172 | {{ history.text }} 173 | {% if not loop.last %}{{ "\n" }}{% endif %} 174 | {% endfor %} 175 | 176 | {% endif %} 177 | {% if shellman.doc.note %} 178 | .SH "NOTES" 179 | {% for note in shellman.doc.note %} 180 | {{ note.text }} 181 | {% if not loop.last %}{{ "\n" }}{% endif %} 182 | {% endfor %} 183 | 184 | {% endif %} 185 | {% if shellman.doc.seealso %} 186 | .SH "SEE ALSO" 187 | {% for seealso in shellman.doc.seealso %} 188 | {{ seealso.text }} 189 | {% if not loop.last %}{{ "\n" }}{% endif %} 190 | {% endfor %} 191 | {% endif %} 192 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/manpage.md: -------------------------------------------------------------------------------- 1 | {% if shellman.doc.brief %} 2 | **NAME** 3 | {{ shellman.filename }} - {{ shellman.doc.brief[0].text }} 4 | 5 | {% endif %} 6 | {% if shellman.doc.usage %} 7 | **SYNOPSIS** 8 | 9 | {% for usage in shellman.doc.usage %} 10 | {{ usage.program }} {{ usage.command }} 11 | {% endfor %} 12 | 13 | {% endif %} 14 | {% if shellman.doc.desc %} 15 | **DESCRIPTION** 16 | {% for desc in shellman.doc.desc %} 17 | {{ desc.text }} 18 | {% if not loop.last %}{{ "\n" }}{% endif %} 19 | {% endfor %} 20 | 21 | {% endif %} 22 | {% if shellman.doc.option %} 23 | **OPTIONS** 24 | {% for opt_group, opt_list in shellman.doc.option|groupby('group', sort=False) %} 25 | {% if opt_group %} 26 | *{{ opt_group }}* 27 | {% endif %} 28 | {% for option in opt_list %} 29 | {% if option.short %}**`{{ option.short }}`**{% if option.long %},{% endif %} {% endif %}{% if option.long %}**`{{ option.long }}`**{% if option.positional %} {% endif %}{% endif %}{% if option.positional %}*`{{ option.positional }}`*{% endif %} 30 | {{ option.description|e }} 31 | 32 | {% endfor %} 33 | {% endfor %} 34 | 35 | {% endif %} 36 | {% if shellman.doc.env %} 37 | **ENVIRONMENT VARIABLES** 38 | {% for env in shellman.doc.env %} 39 | *`{{ env.name }}`* 40 | {{ env.description|e }} 41 | 42 | {% endfor %} 43 | 44 | {% endif %} 45 | {% if shellman.doc.file %} 46 | **FILES** 47 | {% for file in shellman.doc.file %} 48 | *`{{ file.name }}`* 49 | {{ file.description|e }} 50 | 51 | {% endfor %} 52 | 53 | {% endif %} 54 | {% if shellman.doc.exit %} 55 | **EXIT STATUS** 56 | {% for exit in shellman.doc.exit %} 57 | **`{{ exit.code }}`** 58 | {{ exit.description|e }} 59 | 60 | {% endfor %} 61 | 62 | {% endif %} 63 | {% if shellman.doc.stdin %} 64 | **STANDARD INPUT** 65 | {% for stdin in shellman.doc.stdin %} 66 | {{ stdin.text|e }} 67 | {% if not loop.last %}{{ "\n" }}{% endif %} 68 | {% endfor %} 69 | 70 | {% endif %} 71 | {% if shellman.doc.stdout %} 72 | **STANDARD OUTPUT** 73 | {% for stdout in shellman.doc.stdout %} 74 | {{ stdout.text|e }} 75 | {% if not loop.last %}{{ "\n" }}{% endif %} 76 | {% endfor %} 77 | 78 | {% endif %} 79 | {% if shellman.doc.stderr %} 80 | **STANDARD ERROR** 81 | {% for stderr in shellman.doc.stderr %} 82 | {{ stderr.text|e }} 83 | {% if not loop.last %}{{ "\n" }}{% endif %} 84 | {% endfor %} 85 | 86 | {% endif %} 87 | {% if shellman.doc.function %} 88 | **FUNCTIONS** 89 | {% for function in shellman.doc.function %} 90 | {% include "manpage_function.md" with context %} 91 | {% if not loop.last %}{{ "\n" }}{% endif %} 92 | {% endfor %} 93 | 94 | {% endif %} 95 | {% if shellman.doc.example %} 96 | **EXAMPLES** 97 | {% for example in shellman.doc.example %} 98 | **{{ example.brief|e }}** 99 | {% if example.code %} 100 | ```{{ example.code_lang }} 101 | {{ example.code }} 102 | ``` 103 | {% endif %} 104 | {% if example.description %} 105 | {{ example.description|e }} 106 | {% endif %} 107 | {% endfor %} 108 | 109 | {% endif %} 110 | {% if shellman.doc.error %} 111 | **ERRORS** 112 | {% for error in shellman.doc.error %} 113 | {{ error.text|e }} 114 | {% if not loop.last %}{{ "\n" }}{% endif %} 115 | {% endfor %} 116 | 117 | {% endif %} 118 | {% if shellman.doc.bug %} 119 | **BUGS** 120 | {% for bug in shellman.doc.bug %} 121 | {{ bug.text|e }} 122 | {% if not loop.last %}{{ "\n" }}{% endif %} 123 | {% endfor %} 124 | 125 | {% endif %} 126 | {% if shellman.doc.caveat %} 127 | **CAVEATS** 128 | {% for caveat in shellman.doc.caveat %} 129 | {{ caveat.text|e }} 130 | {% if not loop.last %}{{ "\n" }}{% endif %} 131 | {% endfor %} 132 | 133 | {% endif %} 134 | {% if shellman.doc.author %} 135 | **AUTHORS** 136 | {% for author in shellman.doc.author %} 137 | {{ author.text }} 138 | {% if not loop.last %}{{ "\n" }}{% endif %} 139 | {% endfor %} 140 | 141 | {% endif %} 142 | {% if shellman.doc.copyright %} 143 | **COPYRIGHT** 144 | {% for copyright in shellman.doc.copyright %} 145 | {{ copyright.text|e }} 146 | {% if not loop.last %}{{ "\n" }}{% endif %} 147 | {% endfor %} 148 | 149 | {% endif %} 150 | {% if shellman.doc.license %} 151 | **LICENSE** 152 | {% for license in shellman.doc.license %} 153 | {{ license.text|e }} 154 | {% if not loop.last %}{{ "\n" }}{% endif %} 155 | {% endfor %} 156 | 157 | {% endif %} 158 | {% if shellman.doc.history %} 159 | **HISTORY** 160 | {% for history in shellman.doc.history %} 161 | {{ history.text|e }} 162 | {% if not loop.last %}{{ "\n" }}{% endif %} 163 | {% endfor %} 164 | 165 | {% endif %} 166 | {% if shellman.doc.note %} 167 | **NOTES** 168 | {% for note in shellman.doc.note %} 169 | {{ note.text|e }} 170 | {% if not loop.last %}{{ "\n" }}{% endif %} 171 | {% endfor %} 172 | 173 | {% endif %} 174 | {% if shellman.doc.seealso %} 175 | **SEE ALSO** 176 | {% for seealso in shellman.doc.seealso %} 177 | {{ seealso.text|e }} 178 | {% if not loop.last %}{{ "\n" }}{% endif %} 179 | {% endfor %} 180 | {% endif %} 181 | 182 | {% if shellman.credits|default(true) %} 183 | --- 184 | *Man page generated with [shellman](https://github.com/pawamoy/shellman).* 185 | {% endif %} 186 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/manpage_function.groff: -------------------------------------------------------------------------------- 1 | .IP "{{ function.prototype|groffautoescape|groffstrong }}" {{ indent }} 2 | {{ function.brief }} 3 | 4 | {% if function.description %} 5 | {{ function.description }} 6 | 7 | {% endif %} 8 | {% if function.arguments %} 9 | .I Arguments 10 | {% with longest = function.arguments|map('firstword')|map('length')|max %} 11 | {% for argument in function.arguments %} 12 | {{ indent_str }}{{ "{a:{w}}"|format(a=argument|firstword, w=longest)|groffstrong }} - {{ argument|body }} 13 | {% endfor %} 14 | {% endwith %} 15 | 16 | {% endif %} 17 | {% if function.return_codes %} 18 | .I Return codes 19 | {% for return_code in function.return_codes %} 20 | {{ indent_str }}{{ return_code|firstword|groffstrong }} - {{ return_code|body }} 21 | {% endfor %} 22 | 23 | {% endif %} 24 | {% if function.preconditions %} 25 | .I Pre\-conditions 26 | {% for precondition in function.preconditions %} 27 | {{ indent_str }}{{ precondition }} 28 | {% endfor %} 29 | 30 | {% endif %} 31 | {% if function.seealso %} 32 | .I See also 33 | {% for seealso in function.seealso %} 34 | {{ indent_str }}{{ seealso }} 35 | {% endfor %} 36 | 37 | {% endif %} 38 | {% if function.stdin %} 39 | .I Standard input 40 | {% for stdin in function.stdin %} 41 | {{ indent_str }}{{ stdin }} 42 | {% endfor %} 43 | 44 | {% endif %} 45 | {% if function.stdout %} 46 | .I Standard output 47 | {% for stdout in function.stdout %} 48 | {{ indent_str }}{{ stdout }} 49 | {% endfor %} 50 | 51 | {% endif %} 52 | {% if function.stderr %} 53 | .I Standard error 54 | {% for stderr in function.stderr %} 55 | {{ indent_str }}{{ stderr }} 56 | {% endfor %} 57 | 58 | {% endif %} 59 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/manpage_function.md: -------------------------------------------------------------------------------- 1 | **`{{ function.prototype }}`** 2 | {{ function.brief|e }} 3 | 4 | {% if function.description %} 5 | {{ function.description|e }} 6 | 7 | {% endif %} 8 | {% if function.arguments %} 9 | *Arguments* 10 | {% for argument in function.arguments %} 11 | **{{ argument|firstword }}** - {{ argument|body }} 12 | {% endfor %} 13 | 14 | {% endif %} 15 | {% if function.return_codes %} 16 | *Return codes* 17 | {% for return_code in function.return_codes %} 18 | **{{ return_code|firstword|e }}** - {{ return_code|body|e }} 19 | {% endfor %} 20 | 21 | {% endif %} 22 | {% if function.preconditions %} 23 | *Pre-conditions* 24 | {% for precondition in function.preconditions %} 25 | {{ precondition|e }} 26 | {% endfor %} 27 | 28 | {% endif %} 29 | {% if function.seealso %} 30 | *See also* 31 | {% for seealso in function.seealso %} 32 | {{ seealso|e }} 33 | {% endfor %} 34 | 35 | {% endif %} 36 | {% if function.stdin %} 37 | *Standard input* 38 | {% for stdin in function.stdin %} 39 | {{ stdin|e }} 40 | {% endfor %} 41 | 42 | {% endif %} 43 | {% if function.stdout %} 44 | *Standard output* 45 | {% for stdout in function.stdout %} 46 | {{ stdout|e }} 47 | {% endfor %} 48 | 49 | {% endif %} 50 | {% if function.stderr %} 51 | *Standard error* 52 | {% for stderr in function.stderr %} 53 | {{ stderr|e }} 54 | {% endfor %} 55 | 56 | {% endif %} 57 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/usagetext: -------------------------------------------------------------------------------- 1 | {% if shellman.doc.usage %} 2 | usage: {{ shellman.doc.usage[0].program }} {{ shellman.doc.usage[0].command|smartwrap(8 + shellman.doc.usage[0].program|length, indentfirst=False) }} 3 | {% for usage in shellman.doc.usage[1:] %} 4 | {{ usage.program }} {{ usage.command|smartwrap(8 + usage.program|length, indentfirst=False) }} 5 | {% endfor %} 6 | {% endif %} 7 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/wikipage.md: -------------------------------------------------------------------------------- 1 | {% if do_not_escape_lines_that_start_with is not defined %}{% set do_not_escape_lines_that_start_with = None %}{% endif %} 2 | {% if shellman.doc.brief %} 3 | ## {{ shellman.filename }} 4 | {{ shellman.doc.brief[0].text|e }} 5 | 6 | {% include "wikipage_toc.md" with context %} 7 | 8 | {% endif %} 9 | {% if shellman.doc.usage %} 10 | ## Usage 11 | {% for usage in shellman.doc.usage %} 12 | - `{{ usage.program }} {{ usage.command }}` 13 | {% endfor %} 14 | 15 | {% endif %} 16 | {% if shellman.doc.desc %} 17 | ## Description 18 | {% for desc in shellman.doc.desc %} 19 | {{ desc.text|escape(except_starts_with=do_not_escape_lines_that_start_with) }} 20 | {% if not loop.last %}{{ "\n" }}{% endif %} 21 | {% endfor %} 22 | 23 | {% endif %} 24 | {% if shellman.doc.option %} 25 | ## Options 26 | {% for opt_group, opt_list in shellman.doc.option|groupby('group', sort=False) %} 27 | {% if opt_group %} 28 | ### {{ opt_group }} 29 | {% endif %} 30 | {% for option in opt_list %} 31 | - {% if option.short %}**`{{ option.short }}`**{% if option.long %},{% endif %} {% endif %}{% if option.long %}**`{{ option.long }}`**{% if option.positional %} {% endif %}{% endif %}{% if option.positional %}*`{{ option.positional }}`*{% endif %}: 32 | {{ option.description|e|indent(2) }} 33 | {% endfor %} 34 | {% if not loop.last %}{{ '\n' }}{% endif %} 35 | {% endfor %} 36 | 37 | {% endif %} 38 | {% if shellman.doc.env %} 39 | ## Environment Variables 40 | {% for env in shellman.doc.env %} 41 | - *`{{ env.name }}`*: 42 | {{ env.description|e|indent(2) }} 43 | {% endfor %} 44 | 45 | {% endif %} 46 | {% if shellman.doc.file %} 47 | ## Files 48 | {% for file in shellman.doc.file %} 49 | - *`{{ file.name }}`*: 50 | {{ file.description|e|indent(2) }} 51 | {% endfor %} 52 | 53 | {% endif %} 54 | {% if shellman.doc.exit %} 55 | ## Exit Status 56 | {% for exit in shellman.doc.exit %} 57 | - **`{{ exit.code }}`**: 58 | {{ exit.description|e|indent(2) }} 59 | {% endfor %} 60 | 61 | {% endif %} 62 | {% if shellman.doc.stdin %} 63 | ## Standard Input 64 | {% for stdin in shellman.doc.stdin %} 65 | {{ stdin.text|e }} 66 | {% if not loop.last %}{{ "\n" }}{% endif %} 67 | {% endfor %} 68 | 69 | {% endif %} 70 | {% if shellman.doc.stdout %} 71 | ## Standard Output 72 | {% for stdout in shellman.doc.stdout %} 73 | {{ stdout.text|e }} 74 | {% if not loop.last %}{{ "\n" }}{% endif %} 75 | {% endfor %} 76 | 77 | {% endif %} 78 | {% if shellman.doc.stderr %} 79 | ## Standard Error 80 | {% for stderr in shellman.doc.stderr %} 81 | {{ stderr.text|e }} 82 | {% if not loop.last %}{{ "\n" }}{% endif %} 83 | {% endfor %} 84 | 85 | {% endif %} 86 | {% if shellman.doc.function %} 87 | ## Functions 88 | {% for function in shellman.doc.function %} 89 | {% include "wikipage_function.md" with context %} 90 | {% if not loop.last %} 91 | --- 92 | {% endif %} 93 | {% endfor %} 94 | 95 | {% endif %} 96 | {% if shellman.doc.example %} 97 | ## Examples 98 | {% for example in shellman.doc.example %} 99 | - **{{ example.brief|e }}** 100 | {% if example.code %} 101 | 102 | ```{{ example.code_lang }} 103 | {{ example.code }} 104 | ``` 105 | 106 | {% endif %} 107 | {% if example.description %} 108 | {{ example.description|e|indent(2) }} 109 | {% endif %} 110 | {% endfor %} 111 | 112 | {% endif %} 113 | {% if shellman.doc.error %} 114 | ## Errors 115 | {% for error in shellman.doc.error %} 116 | - {{ error.text|e|indent(2) }} 117 | {% if not loop.last %}{{ "\n" }}{% endif %} 118 | {% endfor %} 119 | 120 | {% endif %} 121 | {% if shellman.doc.bug %} 122 | ## Bugs 123 | {% for bug in shellman.doc.bug %} 124 | - {{ bug.text|e|indent(2) }} 125 | {% if not loop.last %}{{ "\n" }}{% endif %} 126 | {% endfor %} 127 | 128 | {% endif %} 129 | {% if shellman.doc.caveat %} 130 | ## Caveats 131 | {% for caveat in shellman.doc.caveat %} 132 | - {{ caveat.text|e|indent(2) }} 133 | {% if not loop.last %}{{ "\n" }}{% endif %} 134 | {% endfor %} 135 | 136 | {% endif %} 137 | {% if shellman.doc.author %} 138 | ## Authors 139 | {% for author in shellman.doc.author %} 140 | - {{ author.text|indent(2) }} 141 | {% if not loop.last %}{{ "\n" }}{% endif %} 142 | {% endfor %} 143 | 144 | {% endif %} 145 | {% if shellman.doc.copyright %} 146 | ## Copyright 147 | {% for copyright in shellman.doc.copyright %} 148 | {{ copyright.text|e }} 149 | {% if not loop.last %}{{ "\n" }}{% endif %} 150 | {% endfor %} 151 | 152 | {% endif %} 153 | {% if shellman.doc.license %} 154 | ## License 155 | {% for license in shellman.doc.license %} 156 | {{ license.text|e }} 157 | {% if not loop.last %}{{ "\n" }}{% endif %} 158 | {% endfor %} 159 | 160 | {% endif %} 161 | {% if shellman.doc.history %} 162 | ## History 163 | {% for history in shellman.doc.history %} 164 | {{ history.text|e }} 165 | {% if not loop.last %}{{ "\n" }}{% endif %} 166 | {% endfor %} 167 | 168 | {% endif %} 169 | {% if shellman.doc.note %} 170 | ## Notes 171 | {% for note in shellman.doc.note %} 172 | {{ note.text|e }} 173 | {% if not loop.last %}{{ "\n" }}{% endif %} 174 | {% endfor %} 175 | 176 | {% endif %} 177 | {% if shellman.doc.seealso %} 178 | ## See Also 179 | {% for seealso in shellman.doc.seealso %} 180 | {{ seealso.text|e }} 181 | {% if not loop.last %}{{ "\n" }}{% endif %} 182 | {% endfor %} 183 | {% endif %} 184 | 185 | {% if shellman.credits|default(true) %} 186 | --- 187 | *Wiki page generated with [shellman](https://github.com/pawamoy/shellman).* 188 | {% endif %} 189 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/wikipage_function.md: -------------------------------------------------------------------------------- 1 | ### `{{ function.prototype }}` 2 | {{ function.brief }} 3 | 4 | {% if function.description %} 5 | {{ function.description }} 6 | 7 | {% endif %} 8 | {% if function.arguments %} 9 | #### Arguments 10 | {% for argument in function.arguments %} 11 | - **`{{ argument|firstword }}`**: {{ argument|body }} 12 | {% endfor %} 13 | 14 | {% endif %} 15 | {% if function.return_codes %} 16 | #### Return codes 17 | {% for return_code in function.return_codes %} 18 | - **`{{ return_code|firstword }}`**: {{ return_code|body }} 19 | {% endfor %} 20 | 21 | {% endif %} 22 | {% if function.preconditions %} 23 | #### Pre-conditions 24 | {% for precondition in function.preconditions %} 25 | - {{ precondition }} 26 | {% endfor %} 27 | 28 | {% endif %} 29 | {% if function.seealso %} 30 | #### See also 31 | {% for seealso in function.seealso %} 32 | - {{ seealso }} 33 | {% endfor %} 34 | 35 | {% endif %} 36 | {% if function.stdin %} 37 | #### Standard input 38 | {% for stdin in function.stdin %} 39 | - {{ stdin }} 40 | {% endfor %} 41 | 42 | {% endif %} 43 | {% if function.stdout %} 44 | #### Standard output 45 | {% for stdout in function.stdout %} 46 | - {{ stdout }} 47 | {% endfor %} 48 | 49 | {% endif %} 50 | {% if function.stderr %} 51 | #### Standard error 52 | {% for stderr in function.stderr %} 53 | - {{ stderr }} 54 | {% endfor %} 55 | 56 | {% endif %} 57 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/data/wikipage_toc.md: -------------------------------------------------------------------------------- 1 | {% if shellman.doc.usage %} 2 | - [Usage](#usage) 3 | {% endif %} 4 | {% if shellman.doc.desc %} 5 | - [Description](#description) 6 | {% endif %} 7 | {% if shellman.doc.option %} 8 | - [Options](#options) 9 | {% endif %} 10 | {% if shellman.doc.env %} 11 | - [Environment Variables](#environment-variables) 12 | {% endif %} 13 | {% if shellman.doc.file %} 14 | - [Files](#files) 15 | {% endif %} 16 | {% if shellman.doc.exit %} 17 | - [Exit Status](#exit-status) 18 | {% endif %} 19 | {% if shellman.doc.stdin %} 20 | - [Standard Input](#standard-input) 21 | {% endif %} 22 | {% if shellman.doc.stdout %} 23 | - [Standard Output](#standard-output) 24 | {% endif %} 25 | {% if shellman.doc.stderr %} 26 | - [Standard Error](#standard-error) 27 | {% endif %} 28 | {% if shellman.doc.function %} 29 | - [Functions](#functions) 30 | {% endif %} 31 | {% if shellman.doc.example %} 32 | - [Examples](#examples) 33 | {% endif %} 34 | {% if shellman.doc.error %} 35 | - [Errors](#errors) 36 | {% endif %} 37 | {% if shellman.doc.bug %} 38 | - [Bugs](#bugs) 39 | {% endif %} 40 | {% if shellman.doc.caveat %} 41 | - [Caveats](#caveats) 42 | {% endif %} 43 | {% if shellman.doc.author %} 44 | - [Authors](#authors) 45 | {% endif %} 46 | {% if shellman.doc.copyright %} 47 | - [Copyright](#copyright) 48 | {% endif %} 49 | {% if shellman.doc.license %} 50 | - [License](#license) 51 | {% endif %} 52 | {% if shellman.doc.history %} 53 | - [History](#history) 54 | {% endif %} 55 | {% if shellman.doc.note %} 56 | - [Notes](#notes) 57 | {% endif %} 58 | {% if shellman.doc.seealso %} 59 | - [See Also](#see-also) 60 | {% endif %} 61 | -------------------------------------------------------------------------------- /src/shellman/_internal/templates/filters.py: -------------------------------------------------------------------------------- 1 | # This module contains Jinja filters. 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import textwrap 7 | from collections import defaultdict 8 | from itertools import groupby 9 | from shutil import get_terminal_size 10 | from typing import TYPE_CHECKING, Any 11 | 12 | from jinja2.filters import _GroupTuple, make_attrgetter, pass_environment 13 | from markupsafe import escape 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Sequence 17 | 18 | from jinja2 import Environment 19 | 20 | 21 | def do_groffautoescape(string: str) -> str: 22 | """Automatically Groff-escape dashes, single/double quotes, dots and dollar signs in a string. 23 | 24 | Parameters: 25 | string: The string to escape. 26 | 27 | Returns: 28 | The escaped string. 29 | """ 30 | return string.replace("-", "\\-").replace("'", "\\'").replace('"', '\\"').replace(".", "\\.").replace("$", "\\f$") 31 | 32 | 33 | def do_groffstrong(string: str) -> str: 34 | """Mark a string as Groff strong. 35 | 36 | Parameters: 37 | string: The string to convert. 38 | 39 | Returns: 40 | The updated string. 41 | """ 42 | return "\\fB" + string + "\\fR" 43 | 44 | 45 | def do_groffemphasis(string: str) -> str: 46 | """Mark a string as Groff emphasis. 47 | 48 | Parameters: 49 | string: The string to convert 50 | 51 | Returns: 52 | The updated string. 53 | """ 54 | return "\\fI" + string + "\\fR" 55 | 56 | 57 | def do_groffautoemphasis(string: str) -> str: 58 | """Automatically mark uppercase words as Groff emphasis. 59 | 60 | Parameters: 61 | string: The string to convert. 62 | 63 | Returns: 64 | The updated string. 65 | """ 66 | return re.sub(r"(\b[A-Z_0-9]{2,}\b)", r"\\fI\1\\fR", string) 67 | 68 | 69 | def do_groffautostrong(string: str) -> str: 70 | """Automatically mark words starting with `-` or `--` as Groff strong. 71 | 72 | Parameters: 73 | string: The string to convert. 74 | 75 | Returns: 76 | The updated string. 77 | """ 78 | return re.sub(r"(--?[\w-]+=?)", r"\\fB\1\\fR", string) 79 | 80 | 81 | def do_groffauto(string: str, *, escape: bool = True) -> str: 82 | """Convert a string to the Groff format. 83 | 84 | Parameters: 85 | string: The string to convert. 86 | escape: Whether to escape the result. 87 | 88 | Returns: 89 | A Groff string. 90 | """ 91 | string = do_groffautoemphasis(string) 92 | string = do_groffautostrong(string) 93 | if escape: 94 | string = do_groffautoescape(string) 95 | return string 96 | 97 | 98 | def do_firstword(string: str, delimiters: str = " ") -> str: 99 | """Get the first word of a string. 100 | 101 | Parameters: 102 | string: The string. 103 | delimiters: The delimiter characters. 104 | 105 | 106 | Returns: 107 | The string's first word. 108 | """ 109 | # FIXME: maybe use a regex instead: ^[\w_]+ 110 | for i, char in enumerate(string): 111 | if char in delimiters: 112 | return string[:i] 113 | return string 114 | 115 | 116 | def do_body(string_or_list: str | Sequence[str], delimiter: str = " ") -> str | None: 117 | """Get the body of a text. 118 | 119 | Parameters: 120 | string_or_list: Given text. 121 | 122 | 123 | Returns: 124 | The text's body. 125 | """ 126 | if isinstance(string_or_list, str): 127 | return string_or_list.split(delimiter, 1)[1] 128 | if isinstance(string_or_list, list): 129 | return "\n".join(string_or_list[1:]) 130 | return None 131 | 132 | 133 | def do_firstline(string_or_list: str | Sequence[str]) -> str | None: 134 | """Get the first line of a text. 135 | 136 | Parameters: 137 | string_or_list: Given text. 138 | 139 | 140 | Returns: 141 | The text's first line. 142 | """ 143 | if isinstance(string_or_list, str): 144 | return string_or_list.split("\n", 1)[0] 145 | if isinstance(string_or_list, list): 146 | return string_or_list[0] 147 | return None 148 | 149 | 150 | def console_width(default: int = 80) -> int: 151 | """Return current console width. 152 | 153 | Parameters: 154 | default: The default value if width cannot be retrieved. 155 | 156 | Returns: 157 | The console width. 158 | """ 159 | # only solution that works with stdin redirected from file 160 | # https://stackoverflow.com/questions/566746 161 | return get_terminal_size((default, 20)).columns 162 | 163 | 164 | def do_smartwrap(text: str, indent: int = 4, width: int | None = None, *, indentfirst: bool = True) -> str: 165 | """Smartly wrap the given text. 166 | 167 | Parameters: 168 | text: The text to wrap. 169 | indent: The indentation to use (number of spaces). 170 | width: The desired text width. 171 | indentfirst: Whether to indent the first line too. 172 | 173 | Returns: 174 | The wrapped text. 175 | """ 176 | if width is None or width < 0: 177 | c_width = console_width(default=79) 178 | if width is None: 179 | width = c_width or 79 180 | else: 181 | width += c_width 182 | 183 | indent_str = indent * " " 184 | to_join = defaultdict(lambda: False) 185 | lines = text.split("\n") 186 | previous = True 187 | for i, line in enumerate(lines): 188 | if not (line == "" or line[0] in (" ", "\t")): 189 | if previous: 190 | to_join[i] = True 191 | previous = True 192 | else: 193 | previous = False 194 | joined_lines = [lines[0]] 195 | for i in range(1, len(lines)): 196 | if to_join[i]: 197 | joined_lines.append(" " + lines[i]) 198 | else: 199 | joined_lines.append("\n" + lines[i]) 200 | new_text = "".join(joined_lines) 201 | new_text_lines = new_text.split("\n") 202 | wrapper = textwrap.TextWrapper(subsequent_indent=indent_str) 203 | wrap_indented_text_lines = [] 204 | first_line = new_text_lines[0] 205 | if not (first_line == "" or first_line[0] in (" ", "\t")): 206 | if indentfirst: 207 | wrapper.width = width 208 | wrapper.initial_indent = indent_str 209 | else: 210 | wrapper.width = width - indent 211 | wrapper.initial_indent = "" 212 | wrap_indented_text_lines.append(wrapper.fill(first_line)) 213 | elif first_line: 214 | wrap_indented_text_lines.append(indent_str + first_line) 215 | else: 216 | wrap_indented_text_lines.append("") 217 | wrapper.width = width 218 | wrapper.initial_indent = indent_str 219 | for line in new_text_lines[1:]: 220 | if not (line == "" or line[0] in (" ", "\t")): 221 | wrap_indented_text_lines.append(wrapper.fill(line)) 222 | elif line: 223 | wrap_indented_text_lines.append(indent_str + line) 224 | else: 225 | wrap_indented_text_lines.append("") 226 | return "\n".join(wrap_indented_text_lines) 227 | 228 | 229 | def do_format(string: str, *args: Any, **kwargs: Any) -> str: 230 | """Override Jinja's format filter to use format method instead of % operator. 231 | 232 | Parameters: 233 | string: The string to format. 234 | *args: Arguments passed to `str.format`. 235 | **kwargs: Keyword arguments passed to `str.format`. 236 | 237 | 238 | Returns: 239 | The formatted string. 240 | """ 241 | return string.format(*args, **kwargs) 242 | 243 | 244 | @pass_environment 245 | def do_groupby( 246 | environment: Environment, 247 | value: Sequence[Any], 248 | attribute: str, 249 | *, 250 | sort: bool = True, 251 | ) -> list[tuple[str, list[Any]]]: 252 | """Override Jinja's groupby filter to add un(sort) option. 253 | 254 | Parameters: 255 | environment: Passed by Jinja. 256 | value: The value to group. 257 | attribute: The attribute to use for grouping/sorting. 258 | 259 | Returns: 260 | The value grouped by the given attribute. 261 | """ 262 | expr = make_attrgetter(environment, attribute) 263 | 264 | # Original behavior: groups are sorted 265 | if sort: 266 | return [_GroupTuple(key, list(values)) for key, values in groupby(sorted(value, key=expr), expr)] 267 | 268 | # Added behavior: original order of appearance is kept 269 | all_groups = [expr(_) for _ in value] 270 | group_set = set() 271 | unique_groups = [] 272 | for group in all_groups: 273 | if group not in group_set: 274 | unique_groups.append(group) 275 | group_set.add(group) 276 | grouped = {k: list(v) for k, v in groupby(sorted(value, key=expr), expr)} 277 | return [_GroupTuple(group, grouped[group]) for group in unique_groups] 278 | 279 | 280 | def do_escape(value: str, except_starts_with: list[str] | None = None) -> str: 281 | """Escape (HTML) given text. 282 | 283 | Parameters: 284 | except_starts_with: Each line starting with at least one of the prefixes 285 | listed in this parameter will not be escaped. 286 | 287 | Returns: 288 | The escaped text. 289 | """ 290 | predicate = ( 291 | (lambda line: any(line.startswith(string) for string in except_starts_with)) 292 | if except_starts_with is not None 293 | else lambda line: False 294 | ) 295 | return "\n".join(line if line == "" or predicate(line) else escape(line) for line in value.split("\n")) 296 | 297 | 298 | FILTERS = { 299 | "groffstrong": do_groffstrong, 300 | "groffemphasis": do_groffemphasis, 301 | "groffautostrong": do_groffautostrong, 302 | "groffautoemphasis": do_groffautoemphasis, 303 | "groffautoescape": do_groffautoescape, 304 | "groffauto": do_groffauto, 305 | "groupby": do_groupby, 306 | "firstword": do_firstword, 307 | "firstline": do_firstline, 308 | "body": do_body, 309 | "smartwrap": do_smartwrap, 310 | "format": do_format, 311 | "escape": do_escape, 312 | } 313 | """The Jinja filters.""" 314 | -------------------------------------------------------------------------------- /src/shellman/cli.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal import cli 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.cli' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(cli, name) 18 | -------------------------------------------------------------------------------- /src/shellman/context.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal import context 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.context' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(context, name) 18 | -------------------------------------------------------------------------------- /src/shellman/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/shellman/0ac1cf54a47ee2a5910cf02642046f064c788da8/src/shellman/py.typed -------------------------------------------------------------------------------- /src/shellman/reader.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal import reader 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.reader' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(reader, name) 18 | -------------------------------------------------------------------------------- /src/shellman/tags.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal import tags 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.tags' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(tags, name) 18 | -------------------------------------------------------------------------------- /src/shellman/templates/__init__.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal import templates 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.templates' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(templates, name) 18 | -------------------------------------------------------------------------------- /src/shellman/templates/filters.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import directly from `shellman` instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from shellman._internal.templates import filters 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from 'shellman.templates.filters' is deprecated. Import directly from 'shellman' instead.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(filters, name) 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests suite for `shellman`.""" 2 | 3 | from pathlib import Path 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | TMP_DIR = TESTS_DIR / "tmp" 7 | FIXTURES_DIR = TESTS_DIR / "fixtures" 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration for the pytest test suite.""" 2 | 3 | import os 4 | 5 | 6 | def get_fake_script(name: str) -> str: 7 | """Get path to a fake script. 8 | 9 | Parameters: 10 | name: The script name. 11 | 12 | Returns: 13 | The fake script path. 14 | """ 15 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), "fakescripts", name) 16 | -------------------------------------------------------------------------------- /tests/fakescripts/simple.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ## \brief Just a demo 4 | ## \desc This script actually does nothing. 5 | 6 | main() { 7 | case "$1" in 8 | ## \option -h, --help 9 | ## Print this help and exit. 10 | -h|--help) shellman "$0"; exit 0 ;; 11 | esac 12 | } 13 | 14 | ## \usage demo [-h] 15 | main "$@" 16 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for our own API exposition.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import griffe 10 | import pytest 11 | from mkdocstrings import Inventory 12 | 13 | import shellman 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | 19 | @pytest.fixture(name="loader", scope="module") 20 | def _fixture_loader() -> griffe.GriffeLoader: 21 | loader = griffe.GriffeLoader() 22 | loader.load("shellman") 23 | loader.resolve_aliases() 24 | return loader 25 | 26 | 27 | @pytest.fixture(name="internal_api", scope="module") 28 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 29 | return loader.modules_collection["shellman._internal"] 30 | 31 | 32 | @pytest.fixture(name="public_api", scope="module") 33 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 34 | return loader.modules_collection["shellman"] 35 | 36 | 37 | def _yield_public_objects( 38 | obj: griffe.Module | griffe.Class, 39 | *, 40 | modules: bool = False, 41 | modulelevel: bool = True, 42 | inherited: bool = False, 43 | special: bool = False, 44 | ) -> Iterator[griffe.Object | griffe.Alias]: 45 | for member in obj.all_members.values() if inherited else obj.members.values(): 46 | try: 47 | if member.is_module: 48 | if member.is_alias or not member.is_public: 49 | continue 50 | if modules: 51 | yield member 52 | yield from _yield_public_objects( 53 | member, # type: ignore[arg-type] 54 | modules=modules, 55 | modulelevel=modulelevel, 56 | inherited=inherited, 57 | special=special, 58 | ) 59 | elif member.is_public and (special or not member.is_special): 60 | yield member 61 | else: 62 | continue 63 | if member.is_class and not modulelevel: 64 | yield from _yield_public_objects( 65 | member, # type: ignore[arg-type] 66 | modules=modules, 67 | modulelevel=False, 68 | inherited=inherited, 69 | special=special, 70 | ) 71 | except (griffe.AliasResolutionError, griffe.CyclicAliasError): 72 | continue 73 | 74 | 75 | @pytest.fixture(name="modulelevel_internal_objects", scope="module") 76 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 77 | return list(_yield_public_objects(internal_api, modulelevel=True)) 78 | 79 | 80 | @pytest.fixture(name="internal_objects", scope="module") 81 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 82 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 83 | 84 | 85 | @pytest.fixture(name="public_objects", scope="module") 86 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 87 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 88 | 89 | 90 | @pytest.fixture(name="inventory", scope="module") 91 | def _fixture_inventory() -> Inventory: 92 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 93 | if not inventory_file.exists(): 94 | raise pytest.skip("The objects inventory is not available.") 95 | with inventory_file.open("rb") as file: 96 | return Inventory.parse_sphinx(file) 97 | 98 | 99 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 100 | """All public objects in the internal API are exposed under `shellman`.""" 101 | not_exposed = [ 102 | obj.path 103 | for obj in modulelevel_internal_objects 104 | if obj.name not in shellman.__all__ or not hasattr(shellman, obj.name) 105 | ] 106 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 107 | 108 | 109 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 110 | """All internal objects have unique names.""" 111 | names_to_paths = defaultdict(list) 112 | for obj in modulelevel_internal_objects: 113 | names_to_paths[obj.name].append(obj.path) 114 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 115 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 116 | 117 | 118 | def test_single_locations(public_api: griffe.Module) -> None: 119 | """All objects have a single public location.""" 120 | 121 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 122 | return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 123 | 124 | multiple_locations = {} 125 | for obj_name in shellman.__all__: 126 | obj = public_api[obj_name] 127 | if obj.aliases and ( 128 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 129 | ): 130 | multiple_locations[obj.path] = public_aliases 131 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 132 | f"{path}: {aliases}" for path, aliases in multiple_locations.items() 133 | ) 134 | 135 | 136 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 137 | """All public objects are added to the inventory.""" 138 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 139 | not_in_inventory = [ 140 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 141 | ] 142 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 143 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 144 | 145 | 146 | def test_inventory_matches_api( 147 | inventory: Inventory, 148 | public_objects: list[griffe.Object | griffe.Alias], 149 | loader: griffe.GriffeLoader, 150 | ) -> None: 151 | """The inventory doesn't contain any additional Python object.""" 152 | not_in_api = [] 153 | public_api_paths = {obj.path for obj in public_objects} 154 | public_api_paths.add("shellman") 155 | # YORE: Bump 2: Remove block. 156 | ignore = ( 157 | "shellman.cli", 158 | "shellman.context", 159 | "shellman.reader", 160 | "shellman.tags", 161 | "shellman.templates", 162 | "shellman.templates.filters", 163 | ) 164 | 165 | for item in inventory.values(): 166 | # YORE: Bump 2: Remove line. 167 | if item.name.startswith(ignore): 168 | # YORE: Bump 2: Remove line. 169 | continue 170 | if ( 171 | item.domain == "py" 172 | and "(" not in item.name 173 | and (item.name == "shellman" or item.name.startswith("shellman.")) 174 | ): 175 | obj = loader.modules_collection[item.name] 176 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 177 | not_in_api.append(item.name) 178 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 179 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 180 | 181 | 182 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 183 | """No module docstrings should be written in our internal API. 184 | 185 | The reasoning is that docstrings are addressed to users of the public API, 186 | but internal modules are not exposed to users, so they should not have docstrings. 187 | """ 188 | 189 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 190 | for member in obj.modules.values(): 191 | yield member 192 | yield from _modules(member) 193 | 194 | for obj in _modules(internal_api): 195 | assert not obj.docstring 196 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the CLI.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from shellman import do_groffautoemphasis, do_groffautostrong, do_smartwrap, main 8 | from shellman._internal import debug 9 | from tests.conftest import get_fake_script 10 | 11 | 12 | def test_main() -> None: 13 | """Basic CLI test.""" 14 | assert main([]) == 1 15 | assert main(["-c", "hello=world"]) == 0 16 | assert main([get_fake_script("simple.sh")]) == 0 17 | 18 | 19 | def test_show_help(capsys: pytest.CaptureFixture) -> None: 20 | """Show help. 21 | 22 | Parameters: 23 | capsys: Pytest fixture to capture output. 24 | """ 25 | with pytest.raises(SystemExit): 26 | main(["-h"]) 27 | captured = capsys.readouterr() 28 | assert "shellman" in captured.out 29 | 30 | 31 | def test_do_groffautoemphasis() -> None: 32 | """Test Groff auto-emphasis on uppercase words.""" 33 | string = "I'm SO emphaSIzed!" 34 | assert do_groffautoemphasis(string) == "I'm \\fISO\\fR emphaSIzed!" 35 | 36 | 37 | def test_do_groffautostrong() -> None: 38 | """Test Groff auto-strong on words prefixed with `-` or `--`.""" 39 | string = "I'm -so --strong!" 40 | assert do_groffautostrong(string) == "I'm \\fB-so\\fR \\fB--strong\\fR!" 41 | 42 | 43 | def test_do_smartwrap() -> None: 44 | """Test smart-wrapping algorithm.""" 45 | text = ( 46 | "Some text.\n\n" 47 | "A very long line: Lorem ipsum dolor sit amet, " 48 | "consectetur adipiscing elit, sed do eiusmod tempor incididunt " 49 | "ut labore et dolore magna aliqua." 50 | ) 51 | code_blocks = ( 52 | "Code block:\n hello\nEnd.\n\n " 53 | "another code block\n with very long lines: " 54 | "Lorem ipsum dolor sit amet, consectetur " 55 | "adipiscing elit, sed do eiusmod tempor incididunt " 56 | "ut labore et dolore magna aliqua." 57 | ) 58 | 59 | assert ( 60 | do_smartwrap(text, width=40) == " Some text.\n\n" 61 | " A very long line: Lorem ipsum dolor\n" 62 | " sit amet, consectetur adipiscing\n" 63 | " elit, sed do eiusmod tempor\n" 64 | " incididunt ut labore et dolore magna\n" 65 | " aliqua." 66 | ) 67 | assert ( 68 | do_smartwrap(code_blocks, width=40) == " Code block:\n" 69 | " hello\n" 70 | " End.\n\n" 71 | " another code block\n" 72 | " with very long lines: Lorem ipsum dolor sit amet, " 73 | "consectetur adipiscing elit, sed do eiusmod tempor incididunt " 74 | "ut labore et dolore magna aliqua." 75 | ) 76 | 77 | 78 | def test_show_version(capsys: pytest.CaptureFixture) -> None: 79 | """Show version. 80 | 81 | Parameters: 82 | capsys: Pytest fixture to capture output. 83 | """ 84 | with pytest.raises(SystemExit): 85 | main(["-V"]) 86 | captured = capsys.readouterr() 87 | assert debug._get_version() in captured.out 88 | 89 | 90 | def test_show_debug_info(capsys: pytest.CaptureFixture) -> None: 91 | """Show debug information. 92 | 93 | Parameters: 94 | capsys: Pytest fixture to capture output. 95 | """ 96 | with pytest.raises(SystemExit): 97 | main(["--debug-info"]) 98 | captured = capsys.readouterr().out.lower() 99 | assert "python" in captured 100 | assert "system" in captured 101 | assert "environment" in captured 102 | assert "packages" in captured 103 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | """Tests for the `context` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | from collections import namedtuple 7 | 8 | from shellman._internal.context import _get_cli_context, _get_context, _get_env_context, _update 9 | 10 | 11 | def test_get_cli_context() -> None: 12 | """Test getting context from CLI arguments.""" 13 | assert _get_cli_context([]) == {} 14 | assert _get_cli_context([""]) == {} 15 | assert _get_cli_context([" "]) == {} 16 | 17 | assert _get_cli_context(["hello=world"]) == {"hello": "world"} 18 | assert _get_cli_context(["hello=world", "hello=universe"]) == {"hello": "universe"} 19 | 20 | assert ( 21 | _get_cli_context(["hello.world=universe"]) 22 | == _get_cli_context(["hello=world", "hello.world=universe"]) 23 | == {"hello": {"world": "universe"}} 24 | ) 25 | assert _get_cli_context(["hello.world=universe", "hello=world"]) == {"hello": "world"} 26 | assert _get_cli_context(["hello.world.and.foobars=hello"]) == {"hello": {"world": {"and": {"foobars": "hello"}}}} 27 | 28 | assert _get_cli_context(['{"hello": "world", "number": [1, 2]}']) == {"hello": "world", "number": [1, 2]} 29 | assert _get_cli_context(['{"hello": "world"}', "hello=universe"]) == {"hello": "universe"} 30 | 31 | 32 | def test_get_env_context() -> None: 33 | """Test getting context from environment variables.""" 34 | os.environ["SHELLMAN_CONTEXT_HELLO"] = "world" 35 | assert _get_env_context() == {"hello": "world"} 36 | del os.environ["SHELLMAN_CONTEXT_HELLO"] 37 | 38 | 39 | def test_get_context() -> None: 40 | """Test getting context from default JSON file.""" 41 | args = namedtuple("args", "context_file context")(None, None) # type: ignore[arg-type,call-arg] # noqa: PYI024 42 | assert _get_context(args) == {} # type: ignore[arg-type] 43 | 44 | 45 | def test_update() -> None: 46 | """Test the context updater/merger function.""" 47 | d1 = {"hello": {"world": "what's up?"}} 48 | d2 = {"hello": {"universe": "????"}, "byebye": "universe"} 49 | _update(d1, d2) 50 | assert d1 == {"hello": {"world": "what's up?", "universe": "????"}, "byebye": "universe"} 51 | -------------------------------------------------------------------------------- /tests/test_reader.py: -------------------------------------------------------------------------------- 1 | """Tests for the `reader` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from shellman._internal.reader import _preprocess_lines, _preprocess_stream 6 | from tests.conftest import get_fake_script 7 | 8 | 9 | def test_preprocess_stream() -> None: 10 | """Test pre-processing of a stream.""" 11 | script = get_fake_script("simple.sh") 12 | with open(script) as stream: 13 | assert list(_preprocess_stream(stream)) == [ 14 | (script, 3, "## \\brief Just a demo"), 15 | (script, 4, "## \\desc This script actually does nothing."), 16 | (script, 8, "## \\option -h, --help"), 17 | (script, 9, "## Print this help and exit."), 18 | (script, 14, "## \\usage demo [-h]"), 19 | ] 20 | 21 | 22 | def test_preprocess_lines() -> None: 23 | """Test pre-processing of lines.""" 24 | script = get_fake_script("simple.sh") 25 | with open(script) as stream: 26 | blocks = list(_preprocess_lines(_preprocess_stream(stream))) 27 | assert blocks 28 | -------------------------------------------------------------------------------- /tests/test_tags.py: -------------------------------------------------------------------------------- 1 | """Tests for the `tags` module.""" 2 | 3 | from shellman._internal.reader import DocLine 4 | from shellman._internal.tags import ( 5 | AuthorTag, 6 | BriefTag, 7 | BugTag, 8 | CaveatTag, 9 | CopyrightTag, 10 | DateTag, 11 | DescTag, 12 | EnvTag, 13 | ErrorTag, 14 | ExampleTag, 15 | ExitTag, 16 | FileTag, 17 | FunctionTag, 18 | HistoryTag, 19 | LicenseTag, 20 | NoteTag, 21 | OptionTag, 22 | SeealsoTag, 23 | StderrTag, 24 | StdinTag, 25 | StdoutTag, 26 | UsageTag, 27 | VersionTag, 28 | ) 29 | 30 | 31 | def test_author_tag() -> None: 32 | """Test author tag.""" 33 | lines = [DocLine(tag="author", value="John Doe", path="test_path", lineno=1)] 34 | tag = AuthorTag.from_lines(lines) 35 | assert isinstance(tag, AuthorTag) 36 | assert tag.text == "John Doe" 37 | 38 | 39 | def test_brief_tag() -> None: 40 | """Test brief tag.""" 41 | lines = [DocLine(tag="brief", value="This is a brief summary.", path="test_path", lineno=2)] 42 | tag = BriefTag.from_lines(lines) 43 | assert isinstance(tag, BriefTag) 44 | assert tag.text == "This is a brief summary." 45 | 46 | 47 | def test_bug_tag() -> None: 48 | """Test bug tag.""" 49 | lines = [DocLine(tag="bug", value="Fix issue #123", path="test_path", lineno=3)] 50 | tag = BugTag.from_lines(lines) 51 | assert isinstance(tag, BugTag) 52 | assert tag.text == "Fix issue #123" 53 | 54 | 55 | def test_caveat_tag() -> None: 56 | """Test caveat tag.""" 57 | lines = [DocLine(tag="caveat", value="Use with caution.", path="test_path", lineno=4)] 58 | tag = CaveatTag.from_lines(lines) 59 | assert isinstance(tag, CaveatTag) 60 | assert tag.text == "Use with caution." 61 | 62 | 63 | def test_copyright_tag() -> None: 64 | """Test copyright tag.""" 65 | lines = [DocLine(tag="copyright", value="Copyright 2023.", path="test_path", lineno=5)] 66 | tag = CopyrightTag.from_lines(lines) 67 | assert isinstance(tag, CopyrightTag) 68 | assert tag.text == "Copyright 2023." 69 | 70 | 71 | def test_date_tag() -> None: 72 | """Test date tag.""" 73 | lines = [DocLine(tag="date", value="2023-01-01", path="test_path", lineno=6)] 74 | tag = DateTag.from_lines(lines) 75 | assert isinstance(tag, DateTag) 76 | assert tag.text == "2023-01-01" 77 | 78 | 79 | def test_desc_tag() -> None: 80 | """Test description tag.""" 81 | lines = [DocLine(tag="desc", value="This is a description.", path="test_path", lineno=7)] 82 | tag = DescTag.from_lines(lines) 83 | assert isinstance(tag, DescTag) 84 | assert tag.text == "This is a description." 85 | 86 | 87 | def test_env_tag() -> None: 88 | """Test env tag.""" 89 | lines = [ 90 | DocLine(tag="env", value="VAR_NAME Variable description", path="test_path", lineno=8), 91 | DocLine(tag=None, value="Additional details.", path="test_path", lineno=9), 92 | ] 93 | tag = EnvTag.from_lines(lines) 94 | assert isinstance(tag, EnvTag) 95 | assert tag.name == "VAR_NAME" 96 | assert tag.description == "Variable description\nAdditional details." 97 | 98 | 99 | def test_example_tag() -> None: 100 | """Test example tag.""" 101 | lines = [ 102 | DocLine(tag="example", value="Example brief", path="test_path", lineno=10), 103 | DocLine(tag="example-code", value="bash", path="test_path", lineno=11), 104 | DocLine(tag=None, value="echo 'Hello, World!'", path="test_path", lineno=12), 105 | DocLine(tag="example-description", value="This is an example.", path="test_path", lineno=13), 106 | ] 107 | tag = ExampleTag.from_lines(lines) 108 | assert isinstance(tag, ExampleTag) 109 | assert tag.brief == "Example brief" 110 | assert tag.code == "echo 'Hello, World!'" 111 | assert tag.code_lang == "bash" 112 | assert tag.description == "This is an example." 113 | 114 | 115 | def test_error_tag() -> None: 116 | """Test error tag.""" 117 | lines = [DocLine(tag="error", value="An error occurred.", path="test_path", lineno=14)] 118 | tag = ErrorTag.from_lines(lines) 119 | assert isinstance(tag, ErrorTag) 120 | assert tag.text == "An error occurred." 121 | 122 | 123 | def test_exit_tag() -> None: 124 | """Test exit tag.""" 125 | lines = [ 126 | DocLine(tag="exit", value="1 Error occurred", path="test_path", lineno=15), 127 | DocLine(tag=None, value="Additional details.", path="test_path", lineno=16), 128 | ] 129 | tag = ExitTag.from_lines(lines) 130 | assert isinstance(tag, ExitTag) 131 | assert tag.code == "1" 132 | assert tag.description == "Error occurred\nAdditional details." 133 | 134 | 135 | def test_file_tag() -> None: 136 | """Test file tag.""" 137 | lines = [ 138 | DocLine(tag="file", value="config.yaml Configuration file", path="test_path", lineno=17), 139 | DocLine(tag=None, value="Additional details.", path="test_path", lineno=18), 140 | ] 141 | tag = FileTag.from_lines(lines) 142 | assert isinstance(tag, FileTag) 143 | assert tag.name == "config.yaml" 144 | assert tag.description == "Configuration file\nAdditional details." 145 | 146 | 147 | def test_function_tag() -> None: 148 | """Test function tag.""" 149 | lines = [ 150 | DocLine(tag="function", value="my_function()", path="test_path", lineno=19), 151 | DocLine(tag="function-brief", value="A brief description.", path="test_path", lineno=20), 152 | DocLine(tag="function-description", value="Detailed description.", path="test_path", lineno=21), 153 | DocLine(tag="function-argument", value="arg1: Argument 1", path="test_path", lineno=22), 154 | DocLine(tag="function-return", value="0: Success", path="test_path", lineno=23), 155 | ] 156 | tag = FunctionTag.from_lines(lines) 157 | assert isinstance(tag, FunctionTag) 158 | assert tag.prototype == "my_function()" 159 | assert tag.brief == "A brief description." 160 | assert tag.description == "Detailed description." 161 | assert tag.arguments == ["arg1: Argument 1"] 162 | assert tag.return_codes == ["0: Success"] 163 | 164 | 165 | def test_history_tag() -> None: 166 | """Test history tag.""" 167 | lines = [DocLine(tag="history", value="Initial version.", path="test_path", lineno=24)] 168 | tag = HistoryTag.from_lines(lines) 169 | assert isinstance(tag, HistoryTag) 170 | assert tag.text == "Initial version." 171 | 172 | 173 | def test_license_tag() -> None: 174 | """Test license tag.""" 175 | lines = [DocLine(tag="license", value="MIT License.", path="test_path", lineno=25)] 176 | tag = LicenseTag.from_lines(lines) 177 | assert isinstance(tag, LicenseTag) 178 | assert tag.text == "MIT License." 179 | 180 | 181 | def test_note_tag() -> None: 182 | """Test note tag.""" 183 | lines = [DocLine(tag="note", value="This is a note.", path="test_path", lineno=26)] 184 | tag = NoteTag.from_lines(lines) 185 | assert isinstance(tag, NoteTag) 186 | assert tag.text == "This is a note." 187 | 188 | 189 | def test_option_tag() -> None: 190 | """Test option tag.""" 191 | lines = [ 192 | DocLine(tag="option", value="-h, --help Show help message", path="test_path", lineno=27), 193 | DocLine(tag="option-default", value="False", path="test_path", lineno=28), 194 | DocLine(tag="option-group", value="General", path="test_path", lineno=29), 195 | ] 196 | tag = OptionTag.from_lines(lines) 197 | assert isinstance(tag, OptionTag) 198 | assert tag.short == "-h" 199 | assert tag.long == "--help" 200 | assert tag.positional == "Show help message" 201 | assert tag.default == "False" 202 | assert tag.group == "General" 203 | assert tag.description == "" 204 | 205 | 206 | def test_seealso_tag() -> None: 207 | """Test seealso tag.""" 208 | lines = [DocLine(tag="seealso", value="Related topic.", path="test_path", lineno=30)] 209 | tag = SeealsoTag.from_lines(lines) 210 | assert isinstance(tag, SeealsoTag) 211 | assert tag.text == "Related topic." 212 | 213 | 214 | def test_stderr_tag() -> None: 215 | """Test stderr tag.""" 216 | lines = [DocLine(tag="stderr", value="Error output.", path="test_path", lineno=31)] 217 | tag = StderrTag.from_lines(lines) 218 | assert isinstance(tag, StderrTag) 219 | assert tag.text == "Error output." 220 | 221 | 222 | def test_stdin_tag() -> None: 223 | """Test stdin tag.""" 224 | lines = [DocLine(tag="stdin", value="Input data.", path="test_path", lineno=32)] 225 | tag = StdinTag.from_lines(lines) 226 | assert isinstance(tag, StdinTag) 227 | assert tag.text == "Input data." 228 | 229 | 230 | def test_stdout_tag() -> None: 231 | """Test stdout tag.""" 232 | lines = [DocLine(tag="stdout", value="Output data.", path="test_path", lineno=33)] 233 | tag = StdoutTag.from_lines(lines) 234 | assert isinstance(tag, StdoutTag) 235 | assert tag.text == "Output data." 236 | 237 | 238 | def test_usage_tag() -> None: 239 | """Test usage tag.""" 240 | lines = [ 241 | DocLine(tag="usage", value="my_program command", path="test_path", lineno=34), 242 | DocLine(tag=None, value="Additional usage details.", path="test_path", lineno=35), 243 | ] 244 | tag = UsageTag.from_lines(lines) 245 | assert isinstance(tag, UsageTag) 246 | assert tag.program == "my_program" 247 | assert tag.command == "command\nAdditional usage details." 248 | 249 | 250 | def test_version_tag() -> None: 251 | """Test version tag.""" 252 | lines = [DocLine(tag="version", value="1.0.0", path="test_path", lineno=36)] 253 | tag = VersionTag.from_lines(lines) 254 | assert isinstance(tag, VersionTag) 255 | assert tag.text == "1.0.0" 256 | --------------------------------------------------------------------------------