diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..5fed1b8 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,23 @@ +# Changes here will be overwritten by Copier +_commit: 1.0.0 +_src_path: gh:mkdocstrings/handler-template +author_email: dev@pawamoy.fr +author_fullname: Timothée Mazzucotelli +author_username: pawamoy +copyright_date: '2023' +copyright_holder: Timothée Mazzucotelli +copyright_holder_email: dev@pawamoy.fr +copyright_license: ISC License +insiders: true +insiders_email: insiders@pawamoy.fr +insiders_repository_name: mkdocstrings-shell +language: Shell +project_description: A shell scripts/libraries handler for mkdocstrings. +project_name: mkdocstrings-shell +public_release: false +python_package_distribution_name: mkdocstrings-shell +python_package_import_name: shell +repository_name: shell +repository_namespace: mkdocstrings +repository_provider: github.com + diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f9d77ee --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a502284 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +github: pawamoy +ko_fi: pawamoy +polar: pawamoy +custom: +- https://www.paypal.me/pawamoy diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..eb53940 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,61 @@ +--- +name: Bug report +about: Create a bug report to help us improve. +title: "bug: " +labels: unconfirmed +assignees: [pawamoy] +--- + +### Description of the bug + + +### To Reproduce + + +``` +WRITE MRE / INSTRUCTIONS HERE +``` + +### Full traceback + + +
Full traceback + +```python +PASTE TRACEBACK HERE +``` + +
+ +### Expected behavior + + +### Environment information + + +```bash +python -m mkdocstrings_handlers.shell.debug # | xclip -selection clipboard +``` + +PASTE OUTPUT HERE + +### Additional context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9a28660 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: +- name: I have a question / I need help + url: https://github.com/mkdocstrings/shell/discussions/new?category=q-a + about: Ask and answer questions in the Discussions tab. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2df98fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project. +title: "feature: " +labels: feature +assignees: pawamoy +--- + +### Is your feature request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a0f917d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: ci + +on: + push: + pull_request: + branches: + - main + +defaults: + run: + shell: bash + +env: + LANG: en_US.utf-8 + LC_ALL: en_US.utf-8 + PYTHONIOENCODING: UTF-8 + PYTHON_VERSIONS: "" + +jobs: + + quality: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fetch all tags + run: git fetch --depth=1 --tags + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: make setup + + - name: Check if the documentation builds correctly + run: make check-docs + + - name: Check the code quality + run: make check-quality + + - name: Check if the code is correctly typed + run: make check-types + + - name: Check for vulnerabilities in dependencies + run: make check-dependencies + + - name: Check for breaking changes in the API + run: make check-api + + exclude-test-jobs: + runs-on: ubuntu-latest + outputs: + jobs: ${{ steps.exclude-jobs.outputs.jobs }} + steps: + - id: exclude-jobs + run: | + if ${{ github.repository_owner == 'pawamoy-insiders' }}; then + echo 'jobs=[ + {"os": "macos-latest"}, + {"os": "windows-latest"}, + {"python-version": "3.9"}, + {"python-version": "3.10"}, + {"python-version": "3.11"}, + {"python-version": "3.12"} + ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT + else + echo 'jobs=[]' >> $GITHUB_OUTPUT + fi + + tests: + + needs: exclude-test-jobs + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.python-version == '3.12' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: make setup + + - name: Run the test suite + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7226211 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: release + +on: push +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Fetch all tags + run: git fetch --depth=1 --tags + - name: Setup Python + uses: actions/setup-python@v4 + - name: Install build + if: github.repository_owner == 'pawamoy-insiders' + run: python -m pip install build + - name: Build dists + if: github.repository_owner == 'pawamoy-insiders' + run: python -m build + - name: Upload dists artifact + uses: actions/upload-artifact@v4 + if: github.repository_owner == 'pawamoy-insiders' + with: + name: shell-insiders + path: ./dist/* + - name: Install git-changelog + if: github.repository_owner != 'pawamoy-insiders' + run: pip install git-changelog + - name: Prepare release notes + if: github.repository_owner != 'pawamoy-insiders' + run: git-changelog --release-notes > release-notes.md + - name: Create release with assets + uses: softprops/action-gh-release@v1 + if: github.repository_owner == 'pawamoy-insiders' + with: + files: ./dist/* + - name: Create release + uses: softprops/action-gh-release@v1 + if: github.repository_owner != 'pawamoy-insiders' + with: + body_path: release-notes.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41fee62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# editors +.idea/ +.vscode/ + +# python +*.egg-info/ +*.py[cod] +.venv/ +.venvs/ +/build/ +/dist/ + +# tools +.coverage* +/.pdm-build/ +/htmlcov/ +/site/ + +# cache +.cache/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +__pycache__/ diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile new file mode 100644 index 0000000..1590b41 --- /dev/null +++ b/.gitpod.dockerfile @@ -0,0 +1,6 @@ +FROM gitpod/workspace-full +USER gitpod +ENV PIP_USER=no +RUN pip3 install pipx; \ + pipx install uv; \ + pipx ensurepath diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..23a3c2b --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,13 @@ +vscode: + extensions: + - ms-python.python + +image: + file: .gitpod.dockerfile + +ports: +- port: 8000 + onOpen: notify + +tasks: +- init: make setup diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a87281b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..255e0ee --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +dev@pawamoy.fr. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d38e85a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,151 @@ +# Contributing + +Contributions are welcome, and they are greatly appreciated! +Every little bit helps, and credit will always be given. + +## Environment setup + +Nothing easier! + +Fork and clone the repository, then: + +```bash +cd shell +make setup +``` + +> NOTE: +> If it fails for some reason, +> you'll need to install +> [uv](https://github.com/astral-sh/uv) +> manually. +> +> You can install it with: +> +> ```bash +> python3 -m pip install --user pipx +> pipx install uv +> ``` +> +> Now you can try running `make setup` again, +> or simply `uv install`. + +You now have the dependencies installed. + +Run `make help` to see all the available actions! + +## Tasks + +This project uses [duty](https://github.com/pawamoy/duty) to run tasks. +A Makefile is also provided. The Makefile will try to run certain tasks +on multiple Python versions. If for some reason you don't want to run the task +on multiple Python versions, you run the task directly with `make run duty TASK`. + +The Makefile detects if a virtual environment is activated, +so `make` will work the same with the virtualenv activated or not. + +If you work in VSCode, we provide +[an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) +for the project. + +## Development + +As usual: + +1. create a new branch: `git switch -c feature-or-bugfix-name` +1. edit the code and/or the documentation + +**Before committing:** + +1. run `make format` to auto-format the code +1. run `make check` to check everything (fix any warning) +1. run `make test` to run the tests (fix any issue) +1. if you updated the documentation or the project dependencies: + 1. run `make docs` + 1. go to http://localhost:8000 and check that everything looks good +1. follow our [commit message convention](#commit-message-convention) + +If you are unsure about how to fix or ignore a warning, +just let the continuous integration fail, +and we will help you during review. + +Don't bother updating the changelog, we will take care of this. + +## Commit message convention + +Commit messages must follow our convention based on the +[Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) +or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): + +``` +[(scope)]: Subject + +[Body] +``` + +**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. + +Scope and body are optional. Type can be: + +- `build`: About packaging, building wheels, etc. +- `chore`: About packaging or repo/files management. +- `ci`: About Continuous Integration. +- `deps`: Dependencies update. +- `docs`: About documentation. +- `feat`: New feature. +- `fix`: Bug fix. +- `perf`: About performance. +- `refactor`: Changes that are not features or bug fixes. +- `style`: A change in code style/format. +- `tests`: About tests. + +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: + +``` +Body. + +Issue #10: https://github.com/namespace/project/issues/10 +Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 +``` + +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). + +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. + +## Pull requests guidelines + +Link to any related issue in the Pull Request message. + +During the review, we recommend using fixups: + +```bash +# SHA is the SHA of the commit you want to fix +git commit --fixup=SHA +``` + +Once all the changes are approved, you can squash your commits: + +```bash +git rebase -i --autosquash main +``` + +And force-push: + +```bash +git push -f +``` + +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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18d0bf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2023, Timothée Mazzucotelli + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..771b333 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# If you have `direnv` loaded in your shell, and allow it in the repository, +# the `make` command will point at the `scripts/make` shell script. +# This Makefile is just here to allow auto-completion in the terminal. + +actions = \ + changelog \ + check \ + check-api \ + check-dependencies \ + check-docs \ + check-quality \ + check-types \ + clean \ + coverage \ + docs \ + docs-deploy \ + format \ + help \ + release \ + run \ + setup \ + test \ + vscode + +.PHONY: $(actions) +$(actions): + @bash scripts/make "$@" diff --git a/README.md b/README.md index 57441f1..6cd0b96 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,39 @@ # mkdocstrings-shell -This project is currently available to [sponsors](https://github.com/sponsors/pawamoy) only. -See the documentation here: https://mkdocstrings.github.io/shell. +[![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://mkdocstrings.github.io/shell/) +[![gitpod](https://img.shields.io/badge/gitpod-workspace-blue.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/shell) +[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#shell:gitter.im) + +A shell scripts/libraries handler for mkdocstrings. +It uses [Shellman](https://github.com/pawamoy/shellman) +to collect documentation from shell scripts. + +## Installation + +This project is available to sponsors only, through my Insiders program. +See Insiders [explanation](https://mkdocstrings.github.io/shell/insiders/) +and [installation instructions](https://mkdocstrings.github.io/shell/insiders/installation/). + +## Configuration + +In MkDocs configuration file: + +```yaml title="mkdocs.yml" +plugins: +- mkdocstrings: + default_handler: shell # optional +``` + +The handler does not offer any option yet. + +## Usage + +Use *mkdocstrings* syntax to inject documentation for a script: + +```md +::: relative/path/to/script + handler: shell +``` + +Specifying `handler: shell` is optional if you declared `shell` +as default handler in mkdocs.yml. diff --git a/config/coverage.ini b/config/coverage.ini new file mode 100644 index 0000000..b56a286 --- /dev/null +++ b/config/coverage.ini @@ -0,0 +1,25 @@ +[coverage:run] +branch = true +parallel = true +source = + src/ + tests/ + +[coverage:paths] +equivalent = + src/ + .venv/lib/*/site-packages/ + .venvs/*/lib/*/site-packages/ + +[coverage:report] +precision = 2 +omit = + src/*/__init__.py + src/*/__main__.py + tests/__init__.py +exclude_lines = + pragma: no cover + if TYPE_CHECKING + +[coverage:json] +output = htmlcov/coverage.json diff --git a/config/git-changelog.toml b/config/git-changelog.toml new file mode 100644 index 0000000..44e2b1f --- /dev/null +++ b/config/git-changelog.toml @@ -0,0 +1,8 @@ +bump = "auto" +convention = "angular" +in-place = true +output = "CHANGELOG.md" +parse-refs = false +parse-trailers = true +sections = ["build", "deps", "feat", "fix", "refactor"] +template = "keepachangelog" diff --git a/config/mypy.ini b/config/mypy.ini new file mode 100644 index 0000000..cb0dd88 --- /dev/null +++ b/config/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +ignore_missing_imports = true +exclude = tests/fixtures/ +warn_unused_ignores = true +show_error_codes = true +namespace_packages = true +explicit_package_bases = true diff --git a/config/pytest.ini b/config/pytest.ini new file mode 100644 index 0000000..ebdeb48 --- /dev/null +++ b/config/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +python_files = + test_*.py + *_test.py + tests.py +addopts = + --cov + --cov-config config/coverage.ini +testpaths = + tests + +# action:message_regex:warning_class:module_regex:line +filterwarnings = + error + # TODO: remove once pytest-xdist 4 is released + ignore:.*rsyncdir:DeprecationWarning:xdist diff --git a/config/ruff.toml b/config/ruff.toml new file mode 100644 index 0000000..0fec686 --- /dev/null +++ b/config/ruff.toml @@ -0,0 +1,84 @@ +target-version = "py38" +line-length = 120 + +[lint] +exclude = [ + "tests/fixtures/*.py", +] +select = [ + "A", "ANN", "ARG", + "B", "BLE", + "C", "C4", + "COM", + "D", "DTZ", + "E", "ERA", "EXE", + "F", "FBT", + "G", + "I", "ICN", "INP", "ISC", + "N", + "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", + "Q", + "RUF", "RSE", "RET", + "S", "SIM", "SLF", + "T", "T10", "T20", "TCH", "TID", "TRY", + "UP", + "W", + "YTT", +] +ignore = [ + "A001", # Variable is shadowing a Python builtin + "ANN101", # Missing type annotation for self + "ANN102", # Missing type annotation for cls + "ANN204", # Missing return type annotation for special method __str__ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "ARG005", # Unused lambda argument + "C901", # Too complex + "D105", # Missing docstring in magic method + "D417", # Missing argument description in the docstring + "E501", # Line too long + "ERA001", # Commented out code + "G004", # Logging statement uses f-string + "PLR0911", # Too many return statements + "PLR0912", # Too many branches + "PLR0913", # Too many arguments to function call + "PLR0915", # Too many statements + "SLF001", # Private member accessed + "TRY003", # Avoid specifying long messages outside the exception class +] + +[lint.per-file-ignores] +"src/*/cli.py" = [ + "T201", # Print statement +] +"src/*/debug.py" = [ + "T201", # Print statement +] +"scripts/*.py" = [ + "INP001", # File is part of an implicit namespace package + "T201", # Print statement +] +"tests/*.py" = [ + "ARG005", # Unused lambda argument + "FBT001", # Boolean positional arg in function definition + "PLR2004", # Magic value used in comparison + "S101", # Use of assert detected +] + +[lint.flake8-quotes] +docstring-quotes = "double" + +[lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[lint.isort] +known-first-party = ["mkdocstrings_handlers.shell"] + +[lint.pydocstyle] +convention = "google" + +[format] +exclude = [ + "tests/fixtures/*.py", +] +docstring-code-format = true +docstring-code-line-length = 80 diff --git a/config/vscode/launch.json b/config/vscode/launch.json new file mode 100644 index 0000000..e328838 --- /dev/null +++ b/config/vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "python (current file)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "docs", + "type": "debugpy", + "request": "launch", + "module": "mkdocs", + "justMyCode": false, + "args": [ + "serve", + "-v" + ] + }, + { + "name": "test", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": [ + "-c=config/pytest.ini", + "-vvv", + "--no-cov", + "--dist=no", + "tests", + "-k=${input:tests_selection}" + ] + } + ], + "inputs": [ + { + "id": "tests_selection", + "type": "promptString", + "description": "Tests selection", + "default": "" + } + ] +} \ No newline at end of file diff --git a/config/vscode/settings.json b/config/vscode/settings.json new file mode 100644 index 0000000..949856d --- /dev/null +++ b/config/vscode/settings.json @@ -0,0 +1,33 @@ +{ + "files.watcherExclude": { + "**/.venv*/**": true, + "**/.venvs*/**": true, + "**/venv*/**": true + }, + "mypy-type-checker.args": [ + "--config-file=config/mypy.ini" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "--config-file=config/pytest.ini" + ], + "ruff.enable": true, + "ruff.format.args": [ + "--config=config/ruff.toml" + ], + "ruff.lint.args": [ + "--config=config/ruff.toml" + ], + "yaml.schemas": { + "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + }, + "yaml.customTags": [ + "!ENV scalar", + "!ENV sequence", + "!relative scalar", + "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", + "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", + "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" + ] +} \ No newline at end of file diff --git a/config/vscode/tasks.json b/config/vscode/tasks.json new file mode 100644 index 0000000..30008cf --- /dev/null +++ b/config/vscode/tasks.json @@ -0,0 +1,103 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "changelog", + "type": "process", + "command": "scripts/make", + "args": ["changelog"] + }, + { + "label": "check", + "type": "process", + "command": "scripts/make", + "args": ["check"] + }, + { + "label": "check-quality", + "type": "process", + "command": "scripts/make", + "args": ["check-quality"] + }, + { + "label": "check-types", + "type": "process", + "command": "scripts/make", + "args": ["check-types"] + }, + { + "label": "check-docs", + "type": "process", + "command": "scripts/make", + "args": ["check-docs"] + }, + { + "label": "check-dependencies", + "type": "process", + "command": "scripts/make", + "args": ["check-dependencies"] + }, + { + "label": "check-api", + "type": "process", + "command": "scripts/make", + "args": ["check-api"] + }, + { + "label": "clean", + "type": "process", + "command": "scripts/make", + "args": ["clean"] + }, + { + "label": "docs", + "type": "process", + "command": "scripts/make", + "args": ["docs"] + }, + { + "label": "docs-deploy", + "type": "process", + "command": "scripts/make", + "args": ["docs-deploy"] + }, + { + "label": "format", + "type": "process", + "command": "scripts/make", + "args": ["format"] + }, + { + "label": "release", + "type": "process", + "command": "scripts/make", + "args": ["release", "${input:version}"] + }, + { + "label": "setup", + "type": "process", + "command": "scripts/make", + "args": ["setup"] + }, + { + "label": "test", + "type": "process", + "command": "scripts/make", + "args": ["test", "coverage"], + "group": "test" + }, + { + "label": "vscode", + "type": "process", + "command": "scripts/make", + "args": ["vscode"] + } + ], + "inputs": [ + { + "id": "version", + "type": "promptString", + "description": "Version" + } + ] +} \ No newline at end of file diff --git a/devdeps.txt b/devdeps.txt new file mode 100644 index 0000000..4fe9799 --- /dev/null +++ b/devdeps.txt @@ -0,0 +1,27 @@ +build>=1.0 +duty>=0.10 +black>=23.9 +markdown-callouts>=0.3 +markdown-exec>=1.7 +mkdocs>=1.5 +mkdocs-coverage>=1.0 +mkdocs-gen-files>=0.5 +mkdocs-git-committers-plugin-2>=1.2 +mkdocs-literate-nav>=0.6 +mkdocs-material>=9.4 +mkdocs-minify-plugin>=0.7 +mkdocstrings[python]>=0.23 +tomli>=2.0; python_version < '3.11' +black>=23.9 +blacken-docs>=1.16 +git-changelog>=2.3 +ruff>=0.0 +pytest>=7.4 +pytest-cov>=4.1 +pytest-randomly>=3.15 +pytest-xdist>=3.3 +mypy>=1.5 +types-markdown>=3.5 +types-pyyaml>=6.0 +safety>=2.3 +twine>=5.0 diff --git a/docs/.overrides/main.html b/docs/.overrides/main.html new file mode 100644 index 0000000..cf8adeb --- /dev/null +++ b/docs/.overrides/main.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block announce %} + + Sponsorship + is now available! + + {% include ".icons/octicons/heart-fill-16.svg" %} + — + + For updates follow @pawamoy on + + + {% include ".icons/fontawesome/brands/mastodon.svg" %} + + Fosstodon + +{% endblock %} diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..786b75d --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +--8<-- "CHANGELOG.md" diff --git a/docs/code_of_conduct.md b/docs/code_of_conduct.md new file mode 100644 index 0000000..01f2ea2 --- /dev/null +++ b/docs/code_of_conduct.md @@ -0,0 +1 @@ +--8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..ea38c9b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +--8<-- "CONTRIBUTING.md" diff --git a/docs/credits.md b/docs/credits.md new file mode 100644 index 0000000..f758db8 --- /dev/null +++ b/docs/credits.md @@ -0,0 +1,10 @@ +--- +hide: +- toc +--- + + +```python exec="yes" +--8<-- "scripts/gen_credits.py" +``` + diff --git a/docs/css/insiders.css b/docs/css/insiders.css new file mode 100644 index 0000000..e7b9c74 --- /dev/null +++ b/docs/css/insiders.css @@ -0,0 +1,124 @@ +@keyframes heart { + + 0%, + 40%, + 80%, + 100% { + transform: scale(1); + } + + 20%, + 60% { + transform: scale(1.15); + } +} + +@keyframes vibrate { + 0%, 2%, 4%, 6%, 8%, 10%, 12%, 14%, 16%, 18% { + -webkit-transform: translate3d(-2px, 0, 0); + transform: translate3d(-2px, 0, 0); + } + 1%, 3%, 5%, 7%, 9%, 11%, 13%, 15%, 17%, 19% { + -webkit-transform: translate3d(2px, 0, 0); + transform: translate3d(2px, 0, 0); + } + 20%, 100% { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +.heart { + color: #e91e63; +} + +.pulse { + animation: heart 1000ms infinite; +} + +.vibrate { + animation: vibrate 2000ms infinite; +} + +.new-feature svg { + fill: var(--md-accent-fg-color) !important; +} + +a.insiders { + color: #e91e63; +} + +.sponsorship-list { + width: 100%; +} + +.sponsorship-item { + border-radius: 100%; + display: inline-block; + height: 1.6rem; + margin: 0.1rem; + overflow: hidden; + width: 1.6rem; +} + +.sponsorship-item:focus, .sponsorship-item:hover { + transform: scale(1.1); +} + +.sponsorship-item img { + filter: grayscale(100%) opacity(75%); + height: auto; + width: 100%; +} + +.sponsorship-item:focus img, .sponsorship-item:hover img { + filter: grayscale(0); +} + +.sponsorship-item.private { + background: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + font-size: .6rem; + font-weight: 700; + line-height: 1.6rem; + text-align: center; +} + +.mastodon { + color: #897ff8; + border-radius: 100%; + box-shadow: inset 0 0 0 .05rem currentcolor; + display: inline-block; + height: 1.2rem !important; + padding: .25rem; + transition: all .25s; + vertical-align: bottom !important; + width: 1.2rem; +} + +.premium-sponsors { + text-align: center; +} + +#silver-sponsors img { + height: 140px; +} + +#bronze-sponsors img { + height: 140px; +} + +#bronze-sponsors p { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +#bronze-sponsors a { + display: block; + flex-shrink: 0; +} + +.sponsors-total { + font-weight: bold; +} \ No newline at end of file diff --git a/docs/css/material.css b/docs/css/material.css new file mode 100644 index 0000000..9e8c14a --- /dev/null +++ b/docs/css/material.css @@ -0,0 +1,4 @@ +/* More space at the bottom of the page. */ +.md-main__inner { + margin-bottom: 1.5rem; +} diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 0000000..727a614 --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,27 @@ +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.external::after, +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + mask-image: url('data:image/svg+xml,'); + -webkit-mask-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + + height: 1em; + width: 1em; + background-color: var(--md-typeset-a-color); +} + +a.external:hover::after, +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} \ No newline at end of file diff --git a/docs/examples/drag b/docs/examples/drag new file mode 100755 index 0000000..efb8e8d --- /dev/null +++ b/docs/examples/drag @@ -0,0 +1,96 @@ +#!/bin/bash + +## \author Timothée Mazzucotelli / @pawamoy + +## \brief Save file paths in a buffer to move them somewhere else. +## \desc This tool lets you save file paths into a buffer before moving or copying +## them somewhere else. It acts like a drag-and-drop utility but for the command-line. +## It can be useful when you don't want to type the entire destination path and +## proceed in three or more steps instead, using shortcut commands to move around your +## filesystem, dragging files from multiple directories. + +## \example Drag files from multiple directories, drop them in another: +## \example-code bash +## cd ~/Documents +## drag ThisPlaylist.s3u +## cd ../Downloads +## drag ThisTrack.ogg AndThisVideo.mp4 +## drag --drop ../project +## \example-description +## In this example, we simply move around in the filesystem, picking files in +## each of these directories. At the end, we drop them all in a specific +## directory. + +## \example Define a convenient `drop` alias: +## \example-code bash +## alias drop='drag -d' +## drag file.txt +## cd /somewhere/else +## drop +## \example-description +## In this example, we define a `drop` alias that allows us to actually +## run `drag` then `drop` (instead of `drag --drop`). + +if [ $# -eq 0 ]; then + shellman "$0" + exit 1 +fi + +data_file="/tmp/dragdrop" + +drop() { + local dir="${2:-.}" + local drop=$1 + [ ! -f "${data_file}" ] && { echo "drag (drop): no files list" >&2; exit 0; } + while read -r f; do + $drop -v "$f" "${dir}" + done < "${data_file}" + rm "${data_file}" +} + +main() { + case $1 in + ## \option -h, --help + ## Print this help and exit. + -h|--help) shellman "$0"; exit 0 ;; + ## \option -d, --drop [DIR] + ## Drop the remembered files in the specified directory + ## (defaul: current directory). + -d|--drop) + drop mv "$2" + exit 0 + ;; + ## \option -p, --copy [DIR] + ## Copy (instead of move) the dragged files in the specified directory + ## (defaul: current directory). + -p|--copy) + drop cp "$2" + exit 0 + ;; + ## \option -c, --clean + ## Clean the currently dragged files list (this option does not delete any file). + -c|--clean) + rm "${data_file}" 2>/dev/null + exit 0 + ;; + ## \option -l, --list + ## List the currently dragged files. + -l|--list) + cat "${data_file}" 2>/dev/null + exit 0 + ;; + esac + + for f; do + if [ "${f:0:1}" = "/" ]; then + echo "$f" >> "${data_file}" + else + echo "${PWD}/$f" >> "${data_file}" + fi + done +} + +## \usage drag FILES +## \usage drag -d|-p [DIR] +## \usage drag -c|-l +main "$@" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d7c1725 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +--8<-- "README.md" + +## Example + +Let say we have a script called `drag` in a scripts folder, +enabling a drag-drop feature on the command-line. + +
View the script's contents: + +```sh +--8<-- "docs/examples/drag" +``` + +
+ +*The documentation syntax used in this script +is documented here: https://pawamoy.github.io/shellman/usage/syntax/.* + +We can inject documentation for our script using this markup: + +```md +::: scripts/drag + handler: shell +``` + +...which would render the following documentation: + +::: docs/examples/drag + handler: shell + options: + heading_level: 2 diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md new file mode 100644 index 0000000..176d4e7 --- /dev/null +++ b/docs/insiders/changelog.md @@ -0,0 +1,7 @@ +# Changelog + +## mkdocstrings-shell Insiders + +### 1.0.0 September 04, 2023 { id="1.0.0" } + +- Release first Insiders version diff --git a/docs/insiders/goals.yml b/docs/insiders/goals.yml new file mode 100644 index 0000000..99b6df2 --- /dev/null +++ b/docs/insiders/goals.yml @@ -0,0 +1,16 @@ +goals: + 500: + name: PlasmaVac User Guide + features: [] + 1000: + name: GraviFridge Fluid Renewal + features: + - name: "[Project] Shell scripts/libraries handler for mkdocstrings" + ref: / + since: 2023/09/04 + 1500: + name: HyperLamp Navigation Tips + features: [] + 2000: + name: FusionDrive Ejection Configuration + features: [] diff --git a/docs/insiders/index.md b/docs/insiders/index.md new file mode 100644 index 0000000..aa8b947 --- /dev/null +++ b/docs/insiders/index.md @@ -0,0 +1,235 @@ +# Insiders + +*mkdocstrings-shell* follows the **sponsorware** release strategy, which means +that new features are first exclusively released to sponsors as part of +[Insiders][insiders]. Read on to learn [what sponsorships achieve][sponsorship], +[how to become a sponsor][sponsors] to get access to Insiders, +and [what's in it for you][features]! + +## What is Insiders? + +*mkdocstrings-shell Insiders* is a private fork of *mkdocstrings-shell*, hosted as +a private GitHub repository. Almost[^1] [all new features][features] +are developed as part of this fork, which means that they are immediately +available to all eligible sponsors, as they are made collaborators of this +repository. + + [^1]: + In general, every new feature is first exclusively released to sponsors, but + sometimes upstream dependencies enhance + existing features that must be supported by *mkdocstrings-shell*. + +Every feature is tied to a [funding goal][funding] in monthly subscriptions. When a +funding goal is hit, the features that are tied to it are merged back into +*mkdocstrings-shell* and released for general availability, making them available +to all users. Bugfixes are always released in tandem. + +Sponsorships start as low as [**$10 a month**][sponsors].[^2] + + [^2]: + Note that $10 a month is the minimum amount to become eligible for + Insiders. While GitHub Sponsors also allows to sponsor lower amounts or + one-time amounts, those can't be granted access to Insiders due to + technical reasons. Such contributions are still very much welcome as + they help ensuring the project's sustainability. + + +## What sponsorships achieve + +Sponsorships make this project sustainable, as they buy the maintainers of this +project time – a very scarce resource – which is spent on the development of new +features, bug fixing, stability improvement, issue triage and general support. +The biggest bottleneck in Open Source is time.[^3] + + [^3]: + Making an Open Source project sustainable is exceptionally hard: maintainers + burn out, projects are abandoned. That's not great and very unpredictable. + The sponsorware model ensures that if you decide to use *mkdocstrings-shell*, + you can be sure that bugs are fixed quickly and new features are added + regularly. + +If you're unsure if you should sponsor this project, check out the list of +[completed funding goals][goals completed] to learn whether you're already using features that +were developed with the help of sponsorships. You're most likely using at least +a handful of them, [thanks to our awesome sponsors][sponsors]! + +## What's in it for me? + +```python exec="1" session="insiders" +data_source = "docs/insiders/goals.yml" +``` + + +```python exec="1" session="insiders" idprefix="" +--8<-- "scripts/insiders.py" + +if unreleased_features: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get **immediate " + f"access to {len(unreleased_features)} additional features** that you can start using right away, and " + "which are currently exclusively available to sponsors:\n" + ) + + for feature in unreleased_features: + feature.render(badge=True) + + print( + "\n\nThese are just the features related to this project. " + "[See the complete feature list on the author's main Insiders page](https://pawamoy.github.io/insiders/#whats-in-it-for-me)." + ) +else: + print( + "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get immediate " + "access to all released features that you can start using right away, and " + "which are exclusively available to sponsors. At this moment, there are no " + "Insiders features for this project, but checkout the [next funding goals](#goals) " + "to see what's coming, as well as **[the feature list for all Insiders projects](https://pawamoy.github.io/insiders/#whats-in-it-for-me).**" + ) +``` + + +## How to become a sponsor + +Thanks for your interest in sponsoring! In order to become an eligible sponsor +with your GitHub account, visit [pawamoy's sponsor profile][github sponsor profile], +and complete a sponsorship of **$10 a month or more**. +You can use your individual or organization GitHub account for sponsoring. + +**Important**: If you're sponsoring **[@pawamoy][github sponsor profile]** +through a GitHub organization, please send a short email +to insiders@pawamoy.fr with the name of your +organization and the GitHub account of the individual +that should be added as a collaborator.[^4] + +You can cancel your sponsorship anytime.[^5] + + [^4]: + It's currently not possible to grant access to each member of an + organization, as GitHub only allows for adding users. Thus, after + sponsoring, please send an email to insiders@pawamoy.fr, stating which + account should become a collaborator of the Insiders repository. We're + working on a solution which will make access to organizations much simpler. + To ensure that access is not tied to a particular individual GitHub account, + create a bot account (i.e. a GitHub account that is not tied to a specific + individual), and use this account for the sponsoring. After being added to + the list of collaborators, the bot account can create a private fork of the + private Insiders GitHub repository, and grant access to all members of the + organizations. + + [^5]: + If you cancel your sponsorship, GitHub schedules a cancellation request + which will become effective at the end of the billing cycle. This means + that even though you cancel your sponsorship, you will keep your access to + Insiders as long as your cancellation isn't effective. All charges are + processed by GitHub through Stripe. As we don't receive any information + regarding your payment, and GitHub doesn't offer refunds, sponsorships are + non-refundable. + + +[:octicons-heart-fill-24:{ .pulse }   Join our awesome sponsors](https://github.com/sponsors/pawamoy){ .md-button .md-button--primary } + +
+
+
+
+
+
+
+ +
+ + + If you sponsor publicly, you're automatically added here with a link to + your profile and avatar to show your support for *mkdocstrings-shell*. + Alternatively, if you wish to keep your sponsorship private, you'll be a + silent +1. You can select visibility during checkout and change it + afterwards. + + +## Funding + +### Goals + +The following section lists all funding goals. Each goal contains a list of +features prefixed with a checkmark symbol, denoting whether a feature is +:octicons-check-circle-fill-24:{ style="color: #00e676" } already available or +:octicons-check-circle-fill-24:{ style="color: var(--md-default-fg-color--lightest)" } planned, +but not yet implemented. When the funding goal is hit, +the features are released for general availability. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if not goal.complete: + goal.render() +``` + +### Goals completed + +This section lists all funding goals that were previously completed, which means +that those features were part of Insiders, but are now generally available and +can be used by all users. + +```python exec="1" session="insiders" idprefix="" +for goal in goals.values(): + if goal.complete: + goal.render() +``` + +## Frequently asked questions + +### Compatibility + +> We're building an open source project and want to allow outside collaborators +to use *mkdocstrings-shell* locally without having access to Insiders. +Is this still possible? + +Yes. Insiders is compatible with *mkdocstrings-shell*. Almost all new features +and configuration options are either backward-compatible or implemented behind +feature flags. Most Insiders features enhance the overall experience, +though while these features add value for the users of your project, they +shouldn't be necessary for previewing when making changes to content. + +### Payment + +> We don't want to pay for sponsorship every month. Are there any other options? + +Yes. You can sponsor on a yearly basis by [switching your GitHub account to a +yearly billing cycle][billing cycle]. If for some reason you cannot do that, you +could also create a dedicated GitHub account with a yearly billing cycle, which +you only use for sponsoring (some sponsors already do that). + +If you have any problems or further questions, please reach out to insiders@pawamoy.fr. + +### Terms + +> Are we allowed to use Insiders under the same terms and conditions as +*mkdocstrings-shell*? + +Yes. Whether you're an individual or a company, you may use *mkdocstrings-shell +Insiders* precisely under the same terms as *mkdocstrings-shell*, which are given +by the [ISC License][license]. However, we kindly ask you to respect our +**fair use policy**: + +- Please **don't distribute the source code** of Insiders. You may freely use + it for public, private or commercial projects, privately fork or mirror it, + but please don't make the source code public, as it would counteract the + sponsorware strategy. + +- If you cancel your subscription, you're automatically removed as a + collaborator and will miss out on all future updates of Insiders. However, you + may **use the latest version** that's available to you **as long as you like**. + Just remember that [GitHub deletes private forks][private forks]. + +[insiders]: #what-is-insiders +[sponsorship]: #what-sponsorships-achieve +[sponsors]: #how-to-become-a-sponsor +[features]: #whats-in-it-for-me +[funding]: #funding +[goals completed]: #goals-completed +[github sponsor profile]: https://github.com/sponsors/pawamoy +[billing cycle]: https://docs.github.com/en/github/setting-up-and-managing-billing-and-payments-on-github/changing-the-duration-of-your-billing-cycle +[license]: ../license.md +[private forks]: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/removing-a-collaborator-from-a-personal-repository + + + diff --git a/docs/insiders/installation.md b/docs/insiders/installation.md new file mode 100644 index 0000000..5d127fc --- /dev/null +++ b/docs/insiders/installation.md @@ -0,0 +1,200 @@ +--- +title: Getting started with Insiders +--- + +# Getting started with Insiders + +*mkdocstrings-shell Insiders* is a compatible drop-in replacement for *mkdocstrings-shell*, +and can be installed similarly using `pip` or `git`. +Note that in order to access the Insiders repository, +you need to [become an eligible sponsor] of @pawamoy on GitHub. + + [become an eligible sponsor]: index.md#how-to-become-a-sponsor + +## Installation + +### with PyPI Insiders + +[PyPI Insiders](https://pawamoy.github.io/pypi-insiders/) +is a tool that helps you keep up-to-date versions +of Insiders projects in the PyPI index of your choice +(self-hosted, Google registry, Artifactory, etc.). + +See [how to install it](https://pawamoy.github.io/pypi-insiders/#installation) +and [how to use it](https://pawamoy.github.io/pypi-insiders/#usage). + +### with pip (ssh/https) + +*mkdocstrings-shell Insiders* can be installed with `pip` [using SSH][using ssh]: + +```bash +pip install git+ssh://git@github.com/pawamoy-insiders/mkdocstrings-shell.git +``` + + [using ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh + +Or using HTTPS: + +```bash +pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/mkdocstrings-shell.git +``` + +>? NOTE: **How to get a GitHub personal access token** +> The `GH_TOKEN` environment variable is a GitHub token. +> It can be obtained by creating a [personal access token] for +> your GitHub account. It will give you access to the Insiders repository, +> programmatically, from the command line or GitHub Actions workflows: +> +> 1. Go to https://github.com/settings/tokens +> 2. Click on [Generate a new token] +> 3. Enter a name and select the [`repo`][scopes] scope +> 4. Generate the token and store it in a safe place +> +> [personal access token]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token +> [Generate a new token]: https://github.com/settings/tokens/new +> [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes +> +> Note that the personal access +> token must be kept secret at all times, as it allows the owner to access your +> private repositories. + +### with pip (self-hosted) + +Self-hosting the Insiders package makes it possible to depend on *mkdocstrings-shell* normally, +while transparently downloading and installing the Insiders version locally. +It means that you can specify your dependencies normally, and your contributors without access +to Insiders will get the public version, while you get the Insiders version on your machine. + +WARNING: **Limitation** +With this method, there is no way to force the installation of an Insiders version +rather than a public version. If there is a public version that is more recent +than your self-hosted Insiders version, the public version will take precedence. +Remember to regularly update your self-hosted versions by uploading latest distributions. + +You can build the distributions for Insiders yourself, by cloning the repository +and using [build] to build the distributions, +or you can download them from our [GitHub Releases]. +You can upload these distributions to a private PyPI-like registry +([Artifactory], [Google Cloud], [pypiserver], etc.) +with [Twine]: + + [build]: https://pypi.org/project/build/ + [Artifactory]: https://jfrog.com/help/r/jfrog-artifactory-documentation/pypi-repositories + [Google Cloud]: https://cloud.google.com/artifact-registry/docs/python + [pypiserver]: https://pypi.org/project/pypiserver/ + [Github Releases]: https://github.com/pawamoy-insiders/mkdocstrings-shell/releases + [Twine]: https://pypi.org/project/twine/ + +```bash +# download distributions in ~/dists, then upload with: +twine upload --repository-url https://your-private-index.com ~/dists/* +``` + +You might also need to provide a username and password/token to authenticate against the registry. +Please check [Twine's documentation][twine docs]. + + [twine docs]: https://twine.readthedocs.io/en/stable/ + +You can then configure pip (or other tools) to look for packages into your package index. +For example, with pip: + +```bash +pip config set global.extra-index-url https://your-private-index.com/simple +``` + +Note that the URL might differ depending on whether your are uploading a package (with Twine) +or installing a package (with pip), and depending on the registry you are using (Artifactory, Google Cloud, etc.). +Please check the documentation of your registry to learn how to configure your environment. + +**We kindly ask that you do not upload the distributions to public registries, +as it is against our [Terms of use](index.md#terms).** + +>? TIP: **Full example with `pypiserver`** +> In this example we use [pypiserver] to serve a local PyPI index. +> +> ```bash +> pip install --user pypiserver +> # or pipx install pypiserver +> +> # create a packages directory +> mkdir -p ~/.local/pypiserver/packages +> +> # run the pypi server without authentication +> pypi-server run -p 8080 -a . -P . ~/.local/pypiserver/packages & +> ``` +> +> We can configure the credentials to access the server in [`~/.pypirc`][pypirc]: +> +> [pypirc]: https://packaging.python.org/en/latest/specifications/pypirc/ +> +> ```ini title=".pypirc" +> [distutils] +> index-servers = +> local +> +> [local] +> repository: http://localhost:8080 +> username: +> password: +> ``` +> +> We then clone the Insiders repository, build distributions and upload them to our local server: +> +> ```bash +> # clone the repository +> git clone git@github.com:pawamoy-insiders/mkdocstrings-shell +> cd mkdocstrings-shell +> +> # install build +> pip install --user build +> # or pipx install build +> +> # checkout latest tag +> git checkout $(git describe --tags --abbrev=0) +> +> # build the distributions +> pyproject-build +> +> # upload them to our local server +> twine upload -r local dist/* --skip-existing +> ``` +> +> Finally, we configure pip, and for example [PDM][pdm], to use our local index to find packages: +> +> ```bash +> pip config set global.extra-index-url http://localhost:8080/simple +> pdm config pypi.extra.url http://localhost:8080/simple +> ``` +> +> [pdm]: https://pdm.fming.dev/latest/ +> +> Now when running `pip install mkdocstrings-shell`, +> or resolving dependencies with PDM, +> both tools will look into our local index and find the Insiders version. +> **Remember to update your local index regularly!** + +### with git + +Of course, you can use *mkdocstrings-shell Insiders* directly from `git`: + +``` +git clone git@github.com:pawamoy-insiders/mkdocstrings-shell +``` + +When cloning from `git`, the package must be installed: + +``` +pip install -e mkdocstrings-shell +``` + +## Upgrading + +When upgrading Insiders, you should always check the version of *mkdocstrings-shell* +which makes up the first part of the version qualifier. For example, a version like +`8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. + +If the major version increased, it's a good idea to consult the [changelog] +and go through the steps to ensure your configuration is up to date and +all necessary changes have been made. + + [changelog]: ./changelog.md diff --git a/docs/js/insiders.js b/docs/js/insiders.js new file mode 100644 index 0000000..8bb6848 --- /dev/null +++ b/docs/js/insiders.js @@ -0,0 +1,74 @@ +function humanReadableAmount(amount) { + const strAmount = String(amount); + if (strAmount.length >= 4) { + return `${strAmount.slice(0, strAmount.length - 3)},${strAmount.slice(-3)}`; + } + return strAmount; +} + +function getJSON(url, callback) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'json'; + xhr.onload = function () { + var status = xhr.status; + if (status === 200) { + callback(null, xhr.response); + } else { + callback(status, xhr.response); + } + }; + xhr.send(); +} + +function updatePremiumSponsors(dataURL, rank) { + let capRank = rank.charAt(0).toUpperCase() + rank.slice(1); + getJSON(dataURL + `/sponsors${capRank}.json`, function (err, sponsors) { + const sponsorsDiv = document.getElementById(`${rank}-sponsors`); + if (sponsors.length > 0) { + let html = ''; + html += `${capRank} sponsors

` + sponsors.forEach(function (sponsor) { + html += ` + + ${sponsor.name} + + ` + }); + html += '

' + sponsorsDiv.innerHTML = html; + } + }); +} + +function updateInsidersPage(author_username) { + const sponsorURL = `https://github.com/sponsors/${author_username}` + const dataURL = `https://raw.githubusercontent.com/${author_username}/sponsors/main`; + getJSON(dataURL + '/numbers.json', function (err, numbers) { + document.getElementById('sponsors-count').innerHTML = numbers.count; + Array.from(document.getElementsByClassName('sponsors-total')).forEach(function (element) { + element.innerHTML = '$ ' + humanReadableAmount(numbers.total); + }); + getJSON(dataURL + '/sponsors.json', function (err, sponsors) { + const sponsorsElem = document.getElementById('sponsors'); + const privateSponsors = numbers.count - sponsors.length; + sponsors.forEach(function (sponsor) { + sponsorsElem.innerHTML += ` + + + + `; + }); + if (privateSponsors > 0) { + sponsorsElem.innerHTML += ` + + +${privateSponsors} + + `; + } + }); + }); + updatePremiumSponsors(dataURL, "gold"); + updatePremiumSponsors(dataURL, "silver"); + updatePremiumSponsors(dataURL, "bronze"); +} diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..a873d2b --- /dev/null +++ b/docs/license.md @@ -0,0 +1,5 @@ +# License + +``` +--8<-- "LICENSE" +``` diff --git a/duties.py b/duties.py new file mode 100644 index 0000000..cc7e7ca --- /dev/null +++ b/duties.py @@ -0,0 +1,308 @@ +"""Development tasks.""" + +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from importlib.metadata import version as pkgversion +from pathlib import Path +from typing import TYPE_CHECKING, Iterator + +from duty import duty +from duty.callables import coverage, lazy, mkdocs, mypy, pytest, ruff, safety + +if TYPE_CHECKING: + from duty.context import Context + + +PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) +PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) +PY_SRC = " ".join(PY_SRC_LIST) +CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} +WINDOWS = os.name == "nt" +PTY = not WINDOWS and not CI +MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" + + +def pyprefix(title: str) -> str: # noqa: D103 + if MULTIRUN: + prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" + return f"{prefix:14}{title}" + return title + + +@contextmanager +def material_insiders() -> Iterator[bool]: # noqa: D103 + if "+insiders" in pkgversion("mkdocs-material"): + os.environ["MATERIAL_INSIDERS"] = "true" + try: + yield True + finally: + os.environ.pop("MATERIAL_INSIDERS") + else: + yield False + + +@duty +def changelog(ctx: Context) -> None: + """Update the changelog in-place with latest commits. + + Parameters: + ctx: The context instance (passed automatically). + """ + from git_changelog.cli import main as git_changelog + + ctx.run(git_changelog, args=[[]], title="Updating changelog") + + +@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) +def check(ctx: Context) -> None: # noqa: ARG001 + """Check it all! + + Parameters: + ctx: The context instance (passed automatically). + """ + + +@duty +def check_quality(ctx: Context) -> None: + """Check the code quality. + + Parameters: + ctx: The context instance (passed automatically). + """ + ctx.run( + ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), + title=pyprefix("Checking code quality"), + command=f"ruff check --config config/ruff.toml {PY_SRC}", + ) + + +@duty +def check_dependencies(ctx: Context) -> None: + """Check for vulnerabilities in dependencies. + + Parameters: + ctx: The context instance (passed automatically). + """ + # retrieve the list of dependencies + requirements = ctx.run( + ["uv", "pip", "freeze"], + silent=True, + allow_overrides=False, + ) + + ctx.run( + safety.check(requirements), + title="Checking dependencies", + command="uv pip freeze | safety check --stdin", + ) + + +@duty +def check_docs(ctx: Context) -> None: + """Check if the documentation builds correctly. + + Parameters: + ctx: The context instance (passed automatically). + """ + Path("htmlcov").mkdir(parents=True, exist_ok=True) + Path("htmlcov/index.html").touch(exist_ok=True) + with material_insiders(): + ctx.run( + mkdocs.build(strict=True, verbose=True), + title=pyprefix("Building documentation"), + command="mkdocs build -vs", + ) + + +@duty +def check_types(ctx: Context) -> None: + """Check that the code is correctly typed. + + Parameters: + ctx: The context instance (passed automatically). + """ + os.environ["MYPYPATH"] = "src" + ctx.run( + mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"), + title=pyprefix("Type-checking"), + command=f"mypy --config-file config/mypy.ini {PY_SRC}", + ) + + +@duty +def check_api(ctx: Context) -> None: + """Check for API breaking changes. + + Parameters: + ctx: The context instance (passed automatically). + """ + from griffe.cli import check as g_check + + griffe_check = lazy(g_check, name="griffe.check") + ctx.run( + griffe_check("mkdocstrings_handlers.shell", search_paths=["src"], color=True), + title="Checking for API breaking changes", + command="griffe check -ssrc mkdocstrings_handlers.shell", + nofail=True, + ) + + +@duty(silent=True) +def clean(ctx: Context) -> None: + """Delete temporary files. + + Parameters: + ctx: The context instance (passed automatically). + """ + + def _rm(*targets: str) -> None: + for target in targets: + ctx.run(f"rm -rf {target}") + + def _find_rm(*targets: str) -> None: + for target in targets: + ctx.run(f"find . -type d -name '{target}' | xargs rm -rf") + + _rm("build", "dist", ".coverage*", "htmlcov", "site", ".pdm-build") + _find_rm(".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__") + + +@duty +def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: + """Serve the documentation (localhost:8000). + + Parameters: + ctx: The context instance (passed automatically). + host: The host to serve the docs from. + port: The port to serve the docs on. + """ + with material_insiders(): + ctx.run( + mkdocs.serve(dev_addr=f"{host}:{port}"), + title="Serving documentation", + capture=False, + ) + + +@duty +def docs_deploy(ctx: Context) -> None: + """Deploy the documentation on GitHub pages. + + Parameters: + ctx: The context instance (passed automatically). + """ + os.environ["DEPLOY"] = "true" + with material_insiders() as insiders: + if not insiders: + ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/mkdocstrings-shell" in origin: + ctx.run( + "git remote add upstream git@github.com:mkdocstrings/shell", + silent=True, + nofail=True, + ) + ctx.run( + mkdocs.gh_deploy(remote_name="upstream", force=True), + title="Deploying documentation", + ) + else: + ctx.run( + lambda: False, + title="Not deploying docs from public repository (do that from insiders instead!)", + nofail=True, + ) + + +@duty +def format(ctx: Context) -> None: + """Run formatting tools on the code. + + Parameters: + ctx: The context instance (passed automatically). + """ + ctx.run( + ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), + title="Auto-fixing code", + ) + ctx.run(ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") + + +@duty(post=["docs-deploy"]) +def release(ctx: Context, version: str) -> None: + """Release a new Python package. + + Parameters: + ctx: The context instance (passed automatically). + version: The new version number to use. + """ + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/shell" in origin: + ctx.run( + lambda: False, + title="Not releasing from insiders repository (do that from public repo instead!)", + ) + ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) + ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) + ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) + ctx.run("git push", title="Pushing commits", pty=False) + ctx.run("git push --tags", title="Pushing tags", pty=False) + ctx.run("pyproject-build", title="Building dist/wheel", pty=PTY) + ctx.run("twine upload --skip-existing dist/*", title="Publishing version", pty=PTY) + + +@duty(silent=True, aliases=["coverage"]) +def cov(ctx: Context) -> None: + """Report coverage as text and HTML. + + Parameters: + ctx: The context instance (passed automatically). + """ + ctx.run(coverage.combine, nofail=True) + ctx.run(coverage.report(rcfile="config/coverage.ini"), capture=False) + ctx.run(coverage.html(rcfile="config/coverage.ini")) + + +@duty +def test(ctx: Context, match: str = "") -> None: + """Run the test suite. + + Parameters: + ctx: The context instance (passed automatically). + match: A pytest expression to filter selected tests. + """ + py_version = f"{sys.version_info.major}{sys.version_info.minor}" + os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" + ctx.run( + pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"), + title=pyprefix("Running tests"), + command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests", + ) + + +@duty +def vscode(ctx: Context) -> None: + """Configure VSCode. + + This task will overwrite the following files, + so make sure to back them up: + + - `.vscode/launch.json` + - `.vscode/settings.json` + - `.vscode/tasks.json` + + Parameters: + ctx: The context instance (passed automatically). + """ + + def update_config(filename: str) -> None: + source_file = Path("config", "vscode", filename) + target_file = Path(".vscode", filename) + target_file.parent.mkdir(exist_ok=True) + target_file.write_text(source_file.read_text()) + + for filename in ("launch.json", "settings.json", "tasks.json"): + ctx.run(update_config, args=[filename], title=f"Update .vscode/{filename}") diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..a207ccb --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,159 @@ +site_name: "mkdocstrings-shell" +site_description: "A shell scripts/libraries handler for mkdocstrings." +site_url: "https://mkdocstrings.github.io/shell" +repo_url: "https://github.com/mkdocstrings/shell" +repo_name: "mkdocstrings/shell" +site_dir: "site" +watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/mkdocstrings_handlers] +copyright: Copyright © 2023 Timothée Mazzucotelli +edit_uri: edit/main/docs/ + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + +nav: +- Home: + - Overview: index.md + - Changelog: changelog.md + - Credits: credits.md + - License: license.md +# defer to gen-files + literate-nav +- API reference: + - mkdocstrings-shell: reference/ +- Development: + - Contributing: contributing.md + - Code of Conduct: code_of_conduct.md + # - Coverage report: coverage.md +- Insiders: + - insiders/index.md + - Getting started: + - Installation: insiders/installation.md + - Changelog: insiders/changelog.md +- mkdocstrings: https://mkdocstrings.github.io/ + +theme: + name: material + custom_dir: docs/.overrides + icon: + logo: material/currency-sign + features: + - announce.dismiss + - content.action.edit + - content.action.view + - content.code.annotate + - content.code.copy + - content.tooltips + - navigation.footer + - navigation.indexes + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - search.highlight + - search.suggest + - toc.follow + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: purple + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: lime + toggle: + icon: material/weather-night + name: Switch to system preference + +extra_css: +- css/material.css +- css/mkdocstrings.css +- css/insiders.css + +markdown_extensions: +- attr_list +- admonition +- callouts +- footnotes +- pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg +- pymdownx.magiclink +- pymdownx.snippets: + base_path: [!relative $config_dir] + check_paths: true +- pymdownx.superfences +- pymdownx.tabbed: + alternate_style: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower +- pymdownx.tasklist: + custom_checkbox: true +- toc: + permalink: "¤" + +plugins: +- search +- markdown-exec +- gen-files: + scripts: + - scripts/gen_ref_nav.py +- literate-nav: + nav_file: SUMMARY.md +# - coverage +- mkdocstrings: + handlers: + python: + import: + - https://docs.python.org/3/objects.inv + - https://mkdocstrings.github.io/objects.inv + paths: [src] + options: + docstring_options: + ignore_init_summary: true + docstring_section_style: list + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_source: false + show_signature_annotations: true + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: true +- git-committers: + enabled: !ENV [DEPLOY, false] + repository: mkdocstrings/shell +- minify: + minify_html: !ENV [DEPLOY, false] +- group: + enabled: !ENV [MATERIAL_INSIDERS, false] + plugins: + - typeset + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/pawamoy + - icon: fontawesome/brands/mastodon + link: https://fosstodon.org/@pawamoy + - icon: fontawesome/brands/twitter + link: https://twitter.com/pawamoy + - icon: fontawesome/brands/gitter + link: https://gitter.im/shell/community + - icon: fontawesome/brands/python + link: https://pypi.org/project/mkdocstrings-shell/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1616186 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[project] +name = "mkdocstrings-shell" +description = "A shell scripts/libraries handler for mkdocstrings." +authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] +license = {text = "ISC"} +readme = "README.md" +requires-python = ">=3.8" +keywords = [] +dynamic = ["version"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Documentation", + "Topic :: Software Development", + "Topic :: Utilities", + "Typing :: Typed", +] +dependencies = [ + "mkdocstrings>=0.18", + "shellman>=1.0.0", +] + +[project.urls] +Homepage = "https://mkdocstrings.github.io/shell" +Documentation = "https://mkdocstrings.github.io/shell" +Changelog = "https://mkdocstrings.github.io/shell/changelog" +Repository = "https://github.com/mkdocstrings/shell" +Issues = "https://github.com/mkdocstrings/shell/issues" +Discussions = "https://github.com/mkdocstrings/shell/discussions" +Gitter = "https://gitter.im/mkdocstrings/shell" +Funding = "https://github.com/sponsors/pawamoy" + +[tool.pdm] +version = {source = "scm"} + +[tool.pdm.build] +package-dir = "src" +includes = ["src/mkdocstrings_handlers"] +editable-backend = "editables" diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py new file mode 100644 index 0000000..27f94d6 --- /dev/null +++ b/scripts/gen_credits.py @@ -0,0 +1,179 @@ +"""Script to generate the project's credits.""" + +from __future__ import annotations + +import os +import sys +from collections import defaultdict +from importlib.metadata import distributions +from itertools import chain +from pathlib import Path +from textwrap import dedent +from typing import Dict, Iterable, Union + +from jinja2 import StrictUndefined +from jinja2.sandbox import SandboxedEnvironment +from packaging.requirements import Requirement + +# TODO: Remove once support for Python 3.10 is dropped. +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) +with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) +project = pyproject["project"] +project_name = project["name"] +with project_dir.joinpath("devdeps.txt").open() as devdeps_file: + devdeps = [line.strip() for line in devdeps_file if not line.startswith("-e")] + +PackageMetadata = Dict[str, Union[str, Iterable[str]]] +Metadata = Dict[str, PackageMetadata] + + +def _merge_fields(metadata: dict) -> PackageMetadata: + fields = defaultdict(list) + for header, value in metadata.items(): + fields[header.lower()].append(value.strip()) + return { + field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] + for field, value in fields.items() + } + + +def _norm_name(name: str) -> str: + return name.replace("_", "-").replace(".", "-").lower() + + +def _requirements(deps: list[str]) -> dict[str, Requirement]: + return {_norm_name((req := Requirement(dep)).name): req for dep in deps} + + +def _extra_marker(req: Requirement) -> str | None: + if not req.marker: + return None + try: + return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") + except StopIteration: + return None + + +def _get_metadata() -> Metadata: + metadata = {} + for pkg in distributions(): + name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] + metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] + metadata[name]["spec"] = set() + metadata[name]["extras"] = set() + metadata[name].setdefault("summary", "") + _set_license(metadata[name]) + return metadata + + +def _set_license(metadata: PackageMetadata) -> None: + license_field = metadata.get("license-expression", metadata.get("license", "")) + license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) + check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") + if check_classifiers: + license_names = [] + for classifier in metadata["classifier"]: + if classifier.startswith("License ::"): + license_names.append(classifier.rsplit("::", 1)[1].strip()) + license_name = " + ".join(license_names) + metadata["license"] = license_name or "?" + + +def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: + deps = {} + for dep_name, dep_req in base_deps.items(): + if dep_name not in metadata: + continue + metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] + metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + + again = True + while again: + again = False + for pkg_name in metadata: + if pkg_name in deps: + for pkg_dependency in metadata[pkg_name].get("requires-dist", []): + requirement = Requirement(pkg_dependency) + dep_name = _norm_name(requirement.name) + extra_marker = _extra_marker(requirement) + if ( + dep_name in metadata + and dep_name not in deps + and dep_name != project["name"] + and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) + ): + metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] + deps[dep_name] = metadata[dep_name] + again = True + + return deps + + +def _render_credits() -> str: + metadata = _get_metadata() + dev_dependencies = _get_deps(_requirements(devdeps), metadata) + prod_dependencies = _get_deps( + _requirements( + chain( # type: ignore[arg-type] + project.get("dependencies", []), + chain(*project.get("optional-dependencies", {}).values()), + ), + ), + metadata, + ) + + template_data = { + "project_name": project_name, + "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"])), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"])), + "more_credits": "http://pawamoy.github.io/credits/", + } + template_text = dedent( + """ + # Credits + + These projects were used to build *{{ project_name }}*. **Thank you!** + + [Python](https://www.python.org/) | + [uv](https://github.com/astral-sh/uv) | + [copier-uv](https://github.com/pawamoy/copier-uv) + + {% macro dep_line(dep) -%} + [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} + {%- endmacro %} + + {% if prod_dependencies -%} + ### Runtime dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in prod_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if dev_dependencies -%} + ### Development dependencies + + Project | Summary | Version (accepted) | Version (last resolved) | License + ------- | ------- | ------------------ | ----------------------- | ------- + {% for dep in dev_dependencies -%} + {{ dep_line(dep) }} + {% endfor %} + + {% endif -%} + {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} + """, + ) + jinja_env = SandboxedEnvironment(undefined=StrictUndefined) + return jinja_env.from_string(template_text).render(**template_data) + + +print(_render_credits()) diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py new file mode 100644 index 0000000..b369536 --- /dev/null +++ b/scripts/gen_ref_nav.py @@ -0,0 +1,37 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() +mod_symbol = '' + +root = Path(__file__).parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = [f"{mod_symbol} {part}" for part in parts] + nav[tuple(nav_parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path.relative_to(root)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/scripts/insiders.py b/scripts/insiders.py new file mode 100644 index 0000000..1521248 --- /dev/null +++ b/scripts/insiders.py @@ -0,0 +1,203 @@ +"""Functions related to Insiders funding goals.""" + +from __future__ import annotations + +import json +import logging +import os +import posixpath +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from itertools import chain +from pathlib import Path +from typing import Iterable, cast +from urllib.error import HTTPError +from urllib.parse import urljoin +from urllib.request import urlopen + +import yaml + +logger = logging.getLogger(f"mkdocs.logs.{__name__}") + + +def human_readable_amount(amount: int) -> str: # noqa: D103 + str_amount = str(amount) + if len(str_amount) >= 4: # noqa: PLR2004 + return f"{str_amount[:len(str_amount)-3]},{str_amount[-3:]}" + return str_amount + + +@dataclass +class Project: + """Class representing an Insiders project.""" + + name: str + url: str + + +@dataclass +class Feature: + """Class representing an Insiders feature.""" + + name: str + ref: str | None + since: date | None + project: Project | None + + def url(self, rel_base: str = "..") -> str | None: # noqa: D102 + if not self.ref: + return None + if self.project: + rel_base = self.project.url + return posixpath.join(rel_base, self.ref.lstrip("/")) + + def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 + new = "" + if badge: + recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 + if recent: + ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] + new = f' :material-alert-decagram:{{ .new-feature .vibrate title="Added on {ft_date}" }}' + project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" + feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name + print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") + + +@dataclass +class Goal: + """Class representing an Insiders goal.""" + + name: str + amount: int + features: list[Feature] + complete: bool = False + + @property + def human_readable_amount(self) -> str: # noqa: D102 + return human_readable_amount(self.amount) + + def render(self, rel_base: str = "..") -> None: # noqa: D102 + print(f"#### $ {self.human_readable_amount} — {self.name}\n") + if self.features: + for feature in self.features: + feature.render(rel_base) + print("") + else: + print("There are no features in this goal for this project. ") + print( + "[See the features in this goal **for all Insiders projects.**]" + f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", + ) + + +def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: + """Load goals from JSON data. + + Parameters: + data: The JSON data. + funding: The current total funding, per month. + origin: The origin of the data (URL). + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + goals_data = yaml.safe_load(data)["goals"] + return { + amount: Goal( + name=goal_data["name"], + amount=amount, + complete=funding >= amount, + features=[ + Feature( + name=feature_data["name"], + ref=feature_data.get("ref"), + since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 + project=project, + ) + for feature_data in goal_data["features"] + ], + ) + for amount, goal_data in goals_data.items() + } + + +def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: + project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") + try: + data = Path(project_dir, path).read_text() + except OSError as error: + raise RuntimeError(f"Could not load data from disk: {path}") from error + return load_goals(data, funding) + + +def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + project_name, project_url, data_fragment = source_data + data_url = urljoin(project_url, data_fragment) + try: + with urlopen(data_url) as response: # noqa: S310 + data = response.read() + except HTTPError as error: + raise RuntimeError(f"Could not load data from network: {data_url}") from error + return load_goals(data, funding, project=Project(name=project_name, url=project_url)) + + +def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + return _load_goals_from_url(source, funding) + + +def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: + """Load funding goals from a given data source. + + Parameters: + source: The data source (local file path or URL). + funding: The current total funding, per month. + + Returns: + A dictionaries of goals, keys being their target monthly amount. + """ + if isinstance(source, str): + return _load_goals_from_disk(source, funding) + goals = {} + for src in source: + source_goals = _load_goals(src, funding) + for amount, goal in source_goals.items(): + if amount not in goals: + goals[amount] = goal + else: + goals[amount].features.extend(goal.features) + return {amount: goals[amount] for amount in sorted(goals)} + + +def feature_list(goals: Iterable[Goal]) -> list[Feature]: + """Extract feature list from funding goals. + + Parameters: + goals: A list of funding goals. + + Returns: + A list of features. + """ + return list(chain.from_iterable(goal.features for goal in goals)) + + +def load_json(url: str) -> str | list | dict: # noqa: D103 + with urlopen(url) as response: # noqa: S310 + return json.loads(response.read().decode()) + + +data_source = globals()["data_source"] +sponsor_url = "https://github.com/sponsors/pawamoy" +data_url = "https://raw.githubusercontent.com/pawamoy/sponsors/main" +numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] +sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] +current_funding = numbers["total"] +sponsors_count = numbers["count"] +goals = funding_goals(data_source, funding=current_funding) +ongoing_goals = [goal for goal in goals.values() if not goal.complete] +unreleased_features = sorted( + (ft for ft in feature_list(ongoing_goals) if ft.since), + key=lambda ft: cast(date, ft.since), + reverse=True, +) diff --git a/scripts/make b/scripts/make new file mode 100755 index 0000000..570fcfa --- /dev/null +++ b/scripts/make @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +set -e +export PYTHON_VERSIONS=${PYTHON_VERSIONS-3.8 3.9 3.10 3.11 3.12} + +exe="" +prefix="" + + +# Install runtime and development dependencies, +# as well as current project in editable mode. +uv_install() { + uv pip compile pyproject.toml devdeps.txt | uv pip install -r - + if [ -z "${CI}" ]; then + uv pip install -e . + else + uv pip install "mkdocstrings-shell @ ." + fi +} + + +# Setup the development environment by installing dependencies +# in multiple Python virtual environments with uv: +# one venv per Python version in `.venvs/$py`, +# and an additional default venv in `.venv`. +setup() { + if ! command -v uv &>/dev/null; then + echo "make: setup: uv must be installed, see https://github.com/astral-sh/uv" >&2 + return 1 + fi + + if [ -n "${PYTHON_VERSIONS}" ]; then + for version in ${PYTHON_VERSIONS}; do + if [ ! -d ".venvs/${version}" ]; then + uv venv --python "${version}" ".venvs/${version}" + fi + VIRTUAL_ENV="${PWD}/.venvs/${version}" uv_install + done + fi + + if [ ! -d .venv ]; then uv venv --python python; fi + uv_install +} + + +# Activate a Python virtual environments. +# The annoying operating system also requires +# that we set some global variables to help it find commands... +activate() { + local path + if [ -f "$1/bin/activate" ]; then + source "$1/bin/activate" + return 0 + fi + if [ -f "$1/Scripts/activate.bat" ]; then + "$1/Scripts/activate.bat" + exe=".exe" + prefix="$1/Scripts/" + return 0 + fi + echo "run: Cannot activate venv $1" >&2 + return 1 +} + + +# Run a command in all configured Python virtual environments. +# We handle the case when the `PYTHON_VERSIONS` environment variable +# is unset or empty, for robustness. +multirun() { + local cmd="$1" + shift + + if [ -n "${PYTHON_VERSIONS}" ]; then + for version in ${PYTHON_VERSIONS}; do + (activate ".venvs/${version}" && MULTIRUN=1 "${prefix}${cmd}${exe}" "$@") + done + else + (activate .venv && "${prefix}${cmd}${exe}" "$@") + fi +} + + +# Run a command in the default Python virtual environment. +# We rely on `multirun`'s handling of empty `PYTHON_VERSIONS`. +singlerun() { + PYTHON_VERSIONS= multirun "$@" +} + + +# Record options following a command name, +# until a non-option argument is met or there are no more arguments. +# Output each option on a new line, so the parent caller can store them in an array. +# Return the number of times the parent caller must shift arguments. +options() { + local shift_count=0 + for arg in "$@"; do + if [[ "${arg}" =~ ^- || "${arg}" =~ ^.+= ]]; then + echo "${arg}" + ((shift_count++)) + else + break + fi + done + return ${shift_count} +} + + +# Main function. +main() { + local cmd + while [ $# -ne 0 ]; do + cmd="$1" + shift + + # Handle `run` early to simplify `case` below. + if [ "${cmd}" = "run" ]; then + singlerun "$@" + exit $? + fi + + # Handle `multirun` early to simplify `case` below. + if [ "${cmd}" = "multirun" ]; then + multirun "$@" + exit $? + fi + + # All commands except `run` and `multirun` can be chained on a single line. + # Some of them accept options in two formats: `-f`, `--flag` and `param=value`. + # Some of them don't, and will print warnings/errors if options were given. + opts=($(options "$@")) || shift $? + + case "${cmd}" in + # The following commands require special handling. + help|"") + singlerun duty --list ;; + setup) + setup ;; + check) + multirun duty check-quality check-types check-docs + singlerun duty check-dependencies check-api + ;; + + # The following commands run in all venvs. + check-quality|\ + check-docs|\ + check-types|\ + test) + multirun duty "${cmd}" "${opts[@]}" ;; + + # The following commands run in the default venv only. + *) + singlerun duty "${cmd}" "${opts[@]}" ;; + esac + done +} + + +# Execute the main function. +main "$@" diff --git a/src/mkdocstrings_handlers/shell/__init__.py b/src/mkdocstrings_handlers/shell/__init__.py new file mode 100644 index 0000000..acc8149 --- /dev/null +++ b/src/mkdocstrings_handlers/shell/__init__.py @@ -0,0 +1,5 @@ +"""Shell handler for mkdocstrings.""" + +from mkdocstrings_handlers.shell.handler import get_handler + +__all__ = ["get_handler"] diff --git a/src/mkdocstrings_handlers/shell/debug.py b/src/mkdocstrings_handlers/shell/debug.py new file mode 100644 index 0000000..eab0bdb --- /dev/null +++ b/src/mkdocstrings_handlers/shell/debug.py @@ -0,0 +1,109 @@ +"""Debugging utilities.""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass +from importlib import metadata + + +@dataclass +class Variable: + """Dataclass describing an environment variable.""" + + name: str + """Variable name.""" + value: str + """Variable value.""" + + +@dataclass +class Package: + """Dataclass describing a Python package.""" + + name: str + """Package name.""" + version: str + """Package version.""" + + +@dataclass +class Environment: + """Dataclass to store environment information.""" + + interpreter_name: str + """Python interpreter name.""" + interpreter_version: str + """Python interpreter version.""" + interpreter_path: str + """Path to Python executable.""" + platform: str + """Operating System.""" + packages: list[Package] + """Installed packages.""" + variables: list[Variable] + """Environment variables.""" + + +def _interpreter_name_version() -> tuple[str, str]: + if hasattr(sys, "implementation"): + impl = sys.implementation.version + version = f"{impl.major}.{impl.minor}.{impl.micro}" + kind = impl.releaselevel + if kind != "final": + version += kind[0] + str(impl.serial) + return sys.implementation.name, version + return "", "0.0.0" + + +def get_version(dist: str = "mkdocstrings-shell") -> str: + """Get version of the given distribution. + + Parameters: + dist: A distribution name. + + Returns: + A version number. + """ + try: + return metadata.version(dist) + except metadata.PackageNotFoundError: + return "0.0.0" + + +def get_debug_info() -> Environment: + """Get debug/environment information. + + Returns: + Environment information. + """ + py_name, py_version = _interpreter_name_version() + packages = ["mkdocstrings-shell"] + variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("MKDOCSTRINGS_SHELL")]] + return Environment( + interpreter_name=py_name, + interpreter_version=py_version, + interpreter_path=sys.executable, + platform=platform.platform(), + variables=[Variable(var, val) for var in variables if (val := os.getenv(var))], + packages=[Package(pkg, get_version(pkg)) for pkg in packages], + ) + + +def print_debug_info() -> None: + """Print debug/environment information.""" + info = get_debug_info() + print(f"- __System__: {info.platform}") + print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") + print("- __Environment variables__:") + for var in info.variables: + print(f" - `{var.name}`: `{var.value}`") + print("- __Installed packages__:") + for pkg in info.packages: + print(f" - `{pkg.name}` v{pkg.version}") + + +if __name__ == "__main__": + print_debug_info() diff --git a/src/mkdocstrings_handlers/shell/handler.py b/src/mkdocstrings_handlers/shell/handler.py new file mode 100644 index 0000000..cd9189d --- /dev/null +++ b/src/mkdocstrings_handlers/shell/handler.py @@ -0,0 +1,132 @@ +"""This module implements a handler for the Shell language.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Mapping, MutableMapping + +from mkdocstrings.handlers.base import BaseHandler, CollectorItem +from mkdocstrings.loggers import get_logger +from shellman.templates.filters import FILTERS + +if TYPE_CHECKING: + from markdown import Markdown + + +logger = get_logger(__name__) + + +class ShellHandler(BaseHandler): + """The Shell handler class.""" + + domain: str = "shell" + """The cross-documentation domain/language for this handler.""" + + enable_inventory: bool = False + """Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file.""" + + fallback_theme = "material" + """The theme to fallback to.""" + + fallback_config: ClassVar[dict] = {"fallback": True} + """The configuration used to collect item during autorefs fallback.""" + + default_config: ClassVar[dict] = { + "show_root_heading": False, + "show_root_toc_entry": True, + "heading_level": 2, + } + """The default configuration options. + + Option | Type | Description | Default + ------ | ---- | ----------- | ------- + **`show_root_heading`** | `bool` | Show the heading of the object at the root of the documentation tree. | `False` + **`show_root_toc_entry`** | `bool` | If the root heading is not shown, at least add a ToC entry for it. | `True` + **`heading_level`** | `int` | The initial heading level to use. | `2` + """ + + def __init__( # noqa: D107 + self, + handler: str, + theme: str, + custom_templates: str | None = None, + config_file_path: str | None = None, + ) -> None: + super().__init__(handler, theme, custom_templates) + if config_file_path: + self.base_dir = Path(config_file_path).parent + else: + self.base_dir = Path(".") + + def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: # noqa: ARG002 + """Collect data given an identifier and selection configuration. + + In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into + a Python dictionary for example, though the implementation is completely free. + + Parameters: + identifier: An identifier that was found in a markdown document for which to collect data. For example, + in Python, it would be 'mkdocstrings.handlers' to collect documentation about the handlers module. + It can be anything that you can feed to the tool of your choice. + config: All configuration options for this handler either defined globally in `mkdocs.yml` or + locally overridden in an identifier block by the user. + + Returns: + Anything you want, as long as you can feed it to the `render` method. + """ + return {"identifier": identifier} + + def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str: # noqa: ARG002 + """Render a template using provided data and configuration options. + + Parameters: + data: The data to render that was collected above in `collect()`. + config: All configuration options for this handler either defined globally in `mkdocs.yml` or + locally overridden in an identifier block by the user. + + Returns: + The rendered template as HTML. + """ + return ( + f"::: {data['identifier']}
The public version of mkdocstrings-shell is a no-op " + "and exists only to allow building docs without errors. Please rely on docs preview in CI.
" + ) + + def update_env(self, md: Markdown, config: dict) -> None: + """Update the Jinja environment with any custom settings/filters/options for this handler. + + Parameters: + md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters. + config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code + of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary. + """ + super().update_env(md, config) # Add some mkdocstrings default filters such as highlight and convert_markdown + self.env.trim_blocks = True + self.env.lstrip_blocks = True + self.env.keep_trailing_newline = False + self.env.filters.update(FILTERS) + + +def get_handler( + theme: str, + custom_templates: str | None = None, + config_file_path: str | None = None, + **config: Any, # noqa: ARG001 +) -> ShellHandler: + """Simply return an instance of `ShellHandler`. + + Parameters: + theme: The theme to use when rendering contents. + custom_templates: Directory containing custom templates. + config_file_path: The MkDocs configuration file path. + **config: Configuration passed to the handler. + + Returns: + An instance of the handler. + """ + return ShellHandler( + handler="shell", + theme=theme, + custom_templates=custom_templates, + config_file_path=config_file_path, + ) diff --git a/src/mkdocstrings_handlers/shell/py.typed b/src/mkdocstrings_handlers/shell/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b4f09e3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests suite for mkdocstrings-shell.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1003210 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,97 @@ +"""Configuration for the pytest test suite.""" + +from __future__ import annotations + +from collections import ChainMap +from typing import TYPE_CHECKING, Any, Iterator + +import pytest +from markdown.core import Markdown +from mkdocs import config +from mkdocs.config.defaults import get_schema + +if TYPE_CHECKING: + from pathlib import Path + + from mkdocstrings.plugin import MkdocstringsPlugin + + from mkdocstrings_handlers.shell.handler import ShellHandler + + +@pytest.fixture(name="mkdocs_conf") +def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[config.Config]: + """Yield a MkDocs configuration object. + + Parameters: + request: Pytest fixture. + tmp_path: Pytest fixture. + + Yields: + MkDocs config. + """ + conf = config.Config(schema=get_schema()) # type: ignore[call-arg] + while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"): + request = request._parent_request + + conf_dict = { + "config_file_path": "mkdocs.yml", + "site_name": "foo", + "site_url": "https://example.org/", + "site_dir": str(tmp_path), + "plugins": [{"mkdocstrings": {"default_handler": "shell"}}], + **getattr(request, "param", {}), + } + # Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289 + mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) + + conf.load_dict(conf_dict) + assert conf.validate() == ([], []) + + conf["mdx_configs"] = mdx_configs + conf["markdown_extensions"].insert(0, "toc") # Guaranteed to be added by MkDocs. + + conf = conf["plugins"]["mkdocstrings"].on_config(conf) + conf = conf["plugins"]["autorefs"].on_config(conf) + yield conf + conf["plugins"]["mkdocstrings"].on_post_build(conf) + + +@pytest.fixture(name="plugin") +def fixture_plugin(mkdocs_conf: config.Config) -> MkdocstringsPlugin: + """Return a plugin instance. + + Parameters: + mkdocs_conf: Pytest fixture (see conftest.py). + + Returns: + mkdocstrings plugin instance. + """ + return mkdocs_conf["plugins"]["mkdocstrings"] + + +@pytest.fixture(name="ext_markdown") +def fixture_ext_markdown(mkdocs_conf: config.Config) -> Markdown: + """Return a Markdown instance with MkdocstringsExtension. + + Parameters: + mkdocs_conf: Pytest fixture (see conftest.py). + + Returns: + A Markdown instance. + """ + return Markdown(extensions=mkdocs_conf["markdown_extensions"], extension_configs=mkdocs_conf["mdx_configs"]) + + +@pytest.fixture(name="handler") +def fixture_handler(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> ShellHandler: + """Return a handler instance. + + Parameters: + plugin: Pytest fixture (see conftest.py). + + Returns: + A handler instance. + """ + handler = plugin.handlers.get_handler("shell") + handler._update_env(ext_markdown, plugin.handlers._config) + return handler # type: ignore[return-value] diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..a11f713 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,40 @@ +"""Tests for the different themes we claim to support.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from markdown import Markdown + from mkdocstrings.plugin import MkdocstringsPlugin + + +@pytest.mark.parametrize( + "plugin", + [ + {"theme": "mkdocs"}, + {"theme": "readthedocs"}, + {"theme": {"name": "material"}}, + ], + indirect=["plugin"], +) +@pytest.mark.parametrize( + "identifier", + [ + # TODO: add identifiers to this list! + ], +) +def test_render_themes_templates_python(identifier: str, plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None: + """Test rendering of a given theme's templates. + + Parameters: + identifier: Parametrized identifier. + plugin: Pytest fixture (see conftest.py). + ext_markdown: Pytest fixture (see conftest.py). + """ + handler = plugin.handlers.get_handler("shell") + handler._update_env(ext_markdown, plugin.handlers._config) + data = handler.collect(identifier, {}) + handler.render(data, {})