[(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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
4 |
5 |
21 |
22 |
23 |
57 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------