diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..3d0b82a --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,23 @@ +# Changes here will be overwritten by Copier +_commit: 1.1.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-typescript +language: TypeScript +project_description: A Typescript handler for mkdocstrings. +project_name: mkdocstrings-typescript +public_release: false +python_package_distribution_name: mkdocstrings-typescript +python_package_import_name: typescript +repository_name: typescript +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/.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..e017920 --- /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 typescript +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..aede0fe --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +# 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 = \ + allrun \ + changelog \ + check \ + check-api \ + check-dependencies \ + check-docs \ + check-quality \ + check-types \ + clean \ + coverage \ + docs \ + docs-deploy \ + format \ + help \ + multirun \ + release \ + run \ + setup \ + test \ + vscode + +.PHONY: $(actions) +$(actions): + @bash scripts/make "$@" diff --git a/README.md b/README.md index 9ee1ce3..5710a8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# typescript +# mkdocstrings-typescript -A TypeScript handler for mkdocstrings. +[![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://mkdocstrings.github.io/typescript/) +[![gitpod](https://img.shields.io/badge/gitpod-workspace-708FCC.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/typescript) +[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#typescript:gitter.im) -This project is currently available to [sponsors](https://github.com/sponsors/pawamoy) only. -See https://mkdocstrings.github.io/typescript/insiders. +A Typescript handler for mkdocstrings. :warning: **Work in progress** :warning: + +## Installation + +This project is available to sponsors only, through my Insiders program. +See Insiders [explanation](https://mkdocstrings.github.io/typescript/insiders/) +and [installation instructions](https://mkdocstrings.github.io/typescript/insiders/installation/). 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..57114e0 --- /dev/null +++ b/config/git-changelog.toml @@ -0,0 +1,9 @@ +bump = "auto" +convention = "angular" +in-place = true +output = "CHANGELOG.md" +parse-refs = false +parse-trailers = true +sections = ["build", "deps", "feat", "fix", "refactor"] +template = "keepachangelog" +versioning = "pep440" 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..c077ca6 --- /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.typescript"] + +[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..6e765d8 --- /dev/null +++ b/devdeps.txt @@ -0,0 +1,33 @@ +# dev +editables>=0.5 + +# maintenance +build>=1.0 +git-changelog>=2.3 +twine>=5.0 + +# ci +duty>=0.10 +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 + +# docs +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' \ No newline at end of file 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..88c7357 --- /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: currentColor; +} + +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/index.md b/docs/index.md new file mode 100644 index 0000000..612c7a5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +--8<-- "README.md" diff --git a/docs/insiders/changelog.md b/docs/insiders/changelog.md new file mode 100644 index 0000000..282529b --- /dev/null +++ b/docs/insiders/changelog.md @@ -0,0 +1,7 @@ +# Changelog + +## mkdocstrings-typescript Insiders + +### 1.0.0 April 22, 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..1297dc6 --- /dev/null +++ b/docs/insiders/goals.yml @@ -0,0 +1,15 @@ +goals: + 500: + name: PlasmaVac User Guide + features: [] + 1000: + name: GraviFridge Fluid Renewal + features: [] + 1500: + name: HyperLamp Navigation Tips + features: [] + 2000: + name: FusionDrive Ejection Configuration + features: + - name: "[Project] A TypeScript handler for mkdocstrings" + ref: / diff --git a/docs/insiders/index.md b/docs/insiders/index.md new file mode 100644 index 0000000..3f08b6f --- /dev/null +++ b/docs/insiders/index.md @@ -0,0 +1,235 @@ +# Insiders + +*mkdocstrings-typescript* 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-typescript Insiders* is a private fork of *mkdocstrings-typescript*, 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-typescript*. + +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-typescript* 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-typescript*, + 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-typescript*. + 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-typescript* locally without having access to Insiders. +Is this still possible? + +Yes. Insiders is compatible with *mkdocstrings-typescript*. 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-typescript*? + +Yes. Whether you're an individual or a company, you may use *mkdocstrings-typescript +Insiders* precisely under the same terms as *mkdocstrings-typescript*, 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..725fbd3 --- /dev/null +++ b/docs/insiders/installation.md @@ -0,0 +1,200 @@ +--- +title: Getting started with Insiders +--- + +# Getting started with Insiders + +*mkdocstrings-typescript Insiders* is a compatible drop-in replacement for *mkdocstrings-typescript*, +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-typescript Insiders* can be installed with `pip` [using SSH][using ssh]: + +```bash +pip install git+ssh://git@github.com/pawamoy-insiders/mkdocstrings-typescript.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-typescript.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-typescript* 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-typescript/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-typescript +> cd mkdocstrings-typescript +> +> # 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-typescript`, +> 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-typescript Insiders* directly from `git`: + +``` +git clone git@github.com:pawamoy-insiders/mkdocstrings-typescript +``` + +When cloning from `git`, the package must be installed: + +``` +pip install -e mkdocstrings-typescript +``` + +## Upgrading + +When upgrading Insiders, you should always check the version of *mkdocstrings-typescript* +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..df2d698 --- /dev/null +++ b/duties.py @@ -0,0 +1,256 @@ +"""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, bump: str = "") -> None: + """Update the changelog in-place with latest commits. + + Parameters: + bump: Bump option passed to git-changelog. + """ + from git_changelog.cli import main as git_changelog + + args = [f"--bump={bump}"] if bump else [] + ctx.run(git_changelog, args=[args], title="Updating changelog", command="git-changelog") + + +@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) +def check(ctx: Context) -> None: # noqa: ARG001 + """Check it all!""" + + +@duty +def check_quality(ctx: Context) -> None: + """Check the code quality.""" + 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.""" + # 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.""" + 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.""" + 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.""" + from griffe.cli import check as g_check + + griffe_check = lazy(g_check, name="griffe.check") + ctx.run( + griffe_check("mkdocstrings_handlers.typescript", search_paths=["src"], color=True), + title="Checking for API breaking changes", + command="griffe check -ssrc mkdocstrings_handlers.typescript", + nofail=True, + ) + + +@duty +def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None: + """Serve the documentation (localhost:8000). + + Parameters: + 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.""" + 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-typescript" in origin: + ctx.run( + "git remote add upstream git@github.com:mkdocstrings/typescript", + 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.""" + 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 +def build(ctx: Context) -> None: + """Build source and wheel distributions.""" + from build.__main__ import main as pyproject_build + + ctx.run( + pyproject_build, + args=[()], + title="Building source and wheel distributions", + command="pyproject-build", + pty=PTY, + ) + + +@duty +def publish(ctx: Context) -> None: + """Publish source and wheel distributions to PyPI.""" + from twine.cli import dispatch as twine_upload + + if not Path("dist").exists(): + ctx.run("false", title="No distribution files found") + dists = [str(dist) for dist in Path("dist").iterdir()] + ctx.run( + twine_upload, + args=[["upload", "-r", "pypi", "--skip-existing", *dists]], + title="Publish source and wheel distributions to PyPI", + command="twine upload -r pypi --skip-existing dist/*", + pty=PTY, + ) + + +@duty(post=["build", "publish", "docs-deploy"]) +def release(ctx: Context, version: str = "") -> None: + """Release a new Python package. + + Parameters: + version: The new version number to use. + """ + origin = ctx.run("git config --get remote.origin.url", silent=True) + if "pawamoy-insiders/typescript" in origin: + ctx.run( + lambda: False, + title="Not releasing from insiders repository (do that from public repo instead!)", + ) + if not (version := (version or input("> Version to release: ")).strip()): + ctx.run("false", title="A version must be provided") + 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) + + +@duty(silent=True, aliases=["coverage"]) +def cov(ctx: Context) -> None: + """Report coverage as text and HTML.""" + 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: + 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", + ) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..dfeb711 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,165 @@ +site_name: "mkdocstrings-typescript" +site_description: "A Typescript handler for mkdocstrings." +site_url: "https://mkdocstrings.github.io/typescript" +repo_url: "https://github.com/mkdocstrings/typescript" +repo_name: "mkdocstrings/typescript" +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-typescript: 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: + typescript: + options: + heading_level: 1 + show_root_heading: true + show_symbol_type_toc: true + show_symbol_type_heading: true + 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_signature_annotations: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + summary: true +- git-committers: + enabled: !ENV [DEPLOY, false] + repository: mkdocstrings/typescript +- 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/typescript/community + - icon: fontawesome/brands/python + link: https://pypi.org/project/mkdocstrings-typescript/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff2aefa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[project] +name = "mkdocstrings-typescript" +description = "A Typescript 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", + "Programming Language :: Python :: 3.13", + "Topic :: Documentation", + "Topic :: Software Development", + "Topic :: Utilities", + "Typing :: Typed", +] +dependencies = [ + "mkdocstrings>=0.24", + "griffe-typedoc>=0.0", +] + +[project.urls] +Homepage = "https://mkdocstrings.github.io/typescript" +Documentation = "https://mkdocstrings.github.io/typescript" +Changelog = "https://mkdocstrings.github.io/typescript/changelog" +Repository = "https://github.com/mkdocstrings/typescript" +Issues = "https://github.com/mkdocstrings/typescript/issues" +Discussions = "https://github.com/mkdocstrings/typescript/discussions" +Gitter = "https://gitter.im/mkdocstrings/typescript" +Funding = "https://github.com/sponsors/pawamoy" + +[tool.pdm] +version = {source = "scm"} + +[tool.pdm.build] +package-dir = "src" +includes = ["src/mkdocstrings_handlers"] +editable-backend = "editables" +source-includes = ["share"] + +[tool.pdm.build.wheel-data] +data = [ + {path = "share/**/*", relative-to = "."}, +] diff --git a/scripts/gen_credits.py b/scripts/gen_credits.py new file mode 100644 index 0000000..8727b83 --- /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 line.strip() and not line.strip().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 or dep_name == "mkdocstrings-typescript": + 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"]).lower()), + "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), + "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..6939e86 --- /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"---\ntitle: {ident}\n---\n\n::: {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..11a3c5f --- /dev/null +++ b/scripts/make @@ -0,0 +1,242 @@ +#!/usr/bin/env bash + +set -e +export PYTHON_VERSIONS=${PYTHON_VERSIONS-3.8 3.9 3.10 3.11 3.12 3.13} + +exe="" +prefix="" + + +# Install runtime and development dependencies, +# as well as current project in editable mode. +uv_install() { + local uv_opts + if [ -n "${UV_RESOLUTION}" ]; then + uv_opts="--resolution=${UV_RESOLUTION}" + fi + uv pip compile ${uv_opts} pyproject.toml devdeps.txt | uv pip install -r - + if [ -z "${CI}" ]; then + uv pip install --no-deps -e . + else + uv pip install --no-deps . + 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 a specific virtual environment. +run() { + local version="$1" + local cmd="$2" + shift 2 + + if [ "${version}" = "default" ]; then + (activate .venv && "${prefix}${cmd}${exe}" "$@") + else + (activate ".venvs/${version}" && MULTIRUN=1 "${prefix}${cmd}${exe}" "$@") + fi +} + + +# Run a command in all configured Python virtual environments. +# We allow `PYTHON_VERSIONS` to be empty, and in that case +# we run the command in the default virtual environment only. +multirun() { + if [ -n "${PYTHON_VERSIONS}" ]; then + for version in ${PYTHON_VERSIONS}; do + run "${version}" "$@" + done + else + run default "$@" + fi +} + + +# Run a command in all configured Python virtual environments, +# as well as in the default virtual environment. +allrun() { + run default "$@" + if [ -n "${PYTHON_VERSIONS}" ]; then + multirun "$@" + fi +} + + +# Clean project by deleting build artifacts and cache files. +clean() { + rm -rf build + rm -rf dist + rm -rf htmlcov + rm -rf site + rm -rf .coverage* + rm -rf .pdm-build + + find . -type d \ + -path ./.venv -prune \ + -path ./.venvs -prune \ + -o -name .cache \ + -o -name .pytest_cache \ + -o -name .mypy_cache \ + -o -name .ruff_cache \ + -o -name __pycache__ | + xargs rm -rf +} + +# 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` +vscode() { + mkdir -p .vscode &>/dev/null + cp -v config/vscode/* .vscode +} + +# 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 + + if [ $# -eq 0 ] || [ "$1" = "help" ]; then + if [ -n "$2" ]; then + run default duty --help "$2" + else + echo "Available commands" + echo " help Print this help. Add task name to print help." + echo " setup Setup all virtual environments (install dependencies)." + echo " run Run a command in the default virtual environment." + echo " multirun Run a command for all configured Python versions." + echo " allrun Run a command in all virtual environments." + echo " 3.x Run a command in the virtual environment for Python 3.x." + echo " clean Delete build artifacts and cache files." + echo " vscode Configure VSCode to work on this project." + if run default python -V &>/dev/null; then + echo + echo "Available tasks" + run default duty --list + fi + fi + exit 0 + fi + + while [ $# -ne 0 ]; do + cmd="$1" + shift + + # Handle `run` early to simplify `case` below. + if [ "${cmd}" = "run" ]; then + run default "$@" + exit $? + fi + + # Handle `multirun` early to simplify `case` below. + if [ "${cmd}" = "multirun" ]; then + multirun "$@" + exit $? + fi + + # Handle `allrun` early to simplify `case` below. + if [ "${cmd}" = "allrun" ]; then + allrun "$@" + exit $? + fi + + # Handle `3.x` early to simplify `case` below. + if [[ "${cmd}" = 3.* ]]; then + run "${cmd}" "$@" + 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. + # The following statement reads options into an array. A syntax quirk means + # that with no options, the array still contains a single empty string. + # In that case, the `options` function returned 0, so we can empty the array. + opts=("$(options "$@")") && opts=() || shift $? + + case "${cmd}" in + # The following commands require special handling. + check) + multirun duty check-quality check-types check-docs + run default duty check-dependencies check-api + ;; + clean|setup|vscode) + "${cmd}" ;; + + # 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. + *) + run default duty "${cmd}" "${opts[@]}" ;; + esac + done +} + + +# Execute the main function. +main "$@" diff --git a/src/mkdocstrings_handlers/typescript/__init__.py b/src/mkdocstrings_handlers/typescript/__init__.py new file mode 100644 index 0000000..abdb6c7 --- /dev/null +++ b/src/mkdocstrings_handlers/typescript/__init__.py @@ -0,0 +1,5 @@ +"""Typescript handler for mkdocstrings.""" + +from mkdocstrings_handlers.typescript.handler import get_handler + +__all__ = ["get_handler"] diff --git a/src/mkdocstrings_handlers/typescript/debug.py b/src/mkdocstrings_handlers/typescript/debug.py new file mode 100644 index 0000000..7c5243e --- /dev/null +++ b/src/mkdocstrings_handlers/typescript/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-typescript") -> 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-typescript"] + variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("MKDOCSTRINGS_TYPESCRIPT")]] + 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/typescript/handler.py b/src/mkdocstrings_handlers/typescript/handler.py new file mode 100644 index 0000000..c4db11c --- /dev/null +++ b/src/mkdocstrings_handlers/typescript/handler.py @@ -0,0 +1,171 @@ +"""This module implements a handler for the Typescript language.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Mapping, MutableMapping + +from mkdocstrings.handlers.base import BaseHandler, CollectorItem +from mkdocstrings.loggers import get_logger + +if TYPE_CHECKING: + from markdown import Markdown + + +logger = get_logger(__name__) + + +class TypescriptHandler(BaseHandler): + """The Typescript handler class.""" + + domain: str = "typescript" + """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] = { + "base_file_path": ".", + "docstring_style": "google", + "docstring_options": {}, + "show_symbol_type_heading": False, + "show_symbol_type_toc": False, + "show_root_heading": False, + "show_root_toc_entry": True, + "show_root_full_path": True, + "show_root_members_full_path": False, + "show_object_full_path": False, + "show_category_heading": False, + "show_if_no_docstring": False, + "show_signature": True, + "show_signature_annotations": False, + "signature_crossrefs": False, + "separate_signature": False, + "line_length": 60, + "merge_init_into_class": False, + "show_docstring_attributes": True, + "show_docstring_functions": True, + "show_docstring_classes": True, + "show_docstring_modules": True, + "show_docstring_description": True, + "show_docstring_examples": True, + "show_docstring_other_parameters": True, + "show_docstring_parameters": True, + "show_docstring_raises": True, + "show_docstring_receives": True, + "show_docstring_returns": True, + "show_docstring_warns": True, + "show_docstring_yields": True, + "show_source": True, + "show_bases": True, + "show_submodules": False, + "group_by_category": True, + "heading_level": 2, + "members_order": "alphabetical", + "docstring_section_style": "table", + "members": None, + "inherited_members": False, + "filters": ["!^_[^_]"], + "annotations_path": "brief", + "preload_modules": None, + "allow_inspection": True, + "summary": False, + "unwrap_annotated": False, + "parameter_headings": False, + } + """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__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the handler. + + Parameters: + *args: Passed to the [base handler][mkdocstrings.handlers.base import BaseHandler]. + **kwargs: Passed to the [base handler][mkdocstrings.handlers.base import BaseHandler]. + """ + kwargs.pop("config_file_path", None) + super().__init__(*args, **kwargs) + self._collected: dict[str, CollectorItem] = {} + + 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-typescript is a no-op " + "and exist 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 + + +def get_handler( + theme: str, + custom_templates: str | None = None, + config_file_path: str | None = None, + **config: Any, # noqa: ARG001 +) -> TypescriptHandler: + """Simply return an instance of `TypescriptHandler`. + + 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 TypescriptHandler( + handler="typescript", + theme=theme, + custom_templates=custom_templates, + config_file_path=config_file_path, + ) diff --git a/src/mkdocstrings_handlers/typescript/py.typed b/src/mkdocstrings_handlers/typescript/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..a1cdd22 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests suite for mkdocstrings-typescript.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c31c321 --- /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.typescript.handler import TypescriptHandler + + +@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": "typescript"}}], + **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) -> TypescriptHandler: + """Return a handler instance. + + Parameters: + plugin: Pytest fixture (see conftest.py). + + Returns: + A handler instance. + """ + handler = plugin.handlers.get_handler("typescript") + 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..d427225 --- /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("typescript") + handler._update_env(ext_markdown, plugin.handlers._config) + data = handler.collect(identifier, {}) + handler.render(data, {})