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