diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9fab73d..46e5cc4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,6 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "monthly" + interval: "monthly" ... diff --git a/.github/scripts/make-github-release.py b/.github/scripts/make-github-release.py new file mode 100644 index 0000000..40ddde1 --- /dev/null +++ b/.github/scripts/make-github-release.py @@ -0,0 +1,90 @@ +import os +import re +import subprocess +import sys +from pathlib import Path + +from pypandoc import convert_text, download_pandoc + + +CWD = Path().cwd() + + +date_pattern = r"\(\d\d\d\d-\d\d-\d\d\)" +version_pattern = r"\d+\.\d+(\.\d+)?(rc\d+)?" + +matches_version_header = re.compile(rf"^{version_pattern} {date_pattern}$").match + + +def extract_relevant_contents(expected_version: str) -> str: + # This isn't solved with a pandoc filter as pandoc only recognizes the level 3 + # headers as such. + + line_feed = iter((CWD / "CHANGES.rst").read_text().splitlines()) + + while True: + line = next(line_feed) + if matches_version_header(line) and line.startswith(expected_version): + break + + assert next(line_feed)[0] == "-" + assert next(line_feed) == "" + + lines: list[str] = [] + while not matches_version_header(line := next(line_feed)): + lines.append(line) + + while lines[-1] == "": + lines.pop() + + return "\n".join(lines) + + +def make_github_release(notes: str, version: str): + options = [] + options.extend(["--notes-file", "-"]) + options.extend(["--title", f"delb {version}"]) + options.append("--verify-tag") + if "rc" in version: + options.append("--prerelease") + + result = subprocess.run( + [ + "gh", + "release", + "create", + ] + + options + + [version], + capture_output=True, + encoding="utf-8", + input=notes, + ) + print(result.stdout) + if result.returncode != 0: + print(result.stdout) + result.check_returncode() + + +def make_release_notes(version: str) -> str: + os.environ["DELB_DOCS_BASE_URL"] = f"https://delb.readthedocs.io/en/{version}/" + return ( + convert_text( + extract_relevant_contents(version), + format="rst", + to="markdown_strict", + extra_args=["--shift-heading-level-by=1", "--wrap=none"], + filters=[".github/scripts/release-notes-pandoc-filter.py"], + ) + + "\n\n----\n\nThe package distributions are available at the " + + f"[Python Package Index](https://pypi.org/project/delb/)." + ) + + +def main(version: str): + download_pandoc() + make_github_release(notes=make_release_notes(version), version=version) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/.github/scripts/release-notes-pandoc-filter.py b/.github/scripts/release-notes-pandoc-filter.py new file mode 100644 index 0000000..06b4da0 --- /dev/null +++ b/.github/scripts/release-notes-pandoc-filter.py @@ -0,0 +1,59 @@ +import os +import posixpath +import sys +from pathlib import Path + +from panflute import run_filter, Code, Link, Str +from sphinx.util.inventory import InventoryFile + + +BASE_URL = os.environ["DELB_DOCS_BASE_URL"] +CWD = Path.cwd() +ROLES_MAPPING = { + "attr": "py:attribute", + "class": "py:class", + "doc": "std:doc", + # ?"exception"?: "py:exception", + "func": "py:function", + "meth": "py:method", + "mod": "py:module", + # ???: "py:property", + "term": "std:term", +} + + +def bend_links(elem, doc): + if not (isinstance(elem, Code) and "interpreted-text" in elem.classes): + return elem + + inventory_section = ROLES_MAPPING[elem.attributes["role"]] + target = doc.inventory[inventory_section].get(elem.text) + + if target is None: + # this is okay for non-existing entries such as :meth:`NodeBase.…` and + # objects in other inventories like Python's docs + print(f"WARNING: No inventory object for '{elem.text}' found.", file=sys.stderr) + return Code(elem.text) + + if (label_text := target[3]) == "-": + label_text = elem.text + + if inventory_section.startswith("py:"): + label = Code(label_text) + else: + label = Str(f"„{label_text}”") + + return Link(label, url=target[2]) + + +def prepare(doc): + with (CWD / "docs" / "build" / "html" / "objects.inv").open("rb") as f: + doc.inventory = InventoryFile.load(f, BASE_URL, posixpath.join) + + +def main(doc=None): + return run_filter(bend_links, prepare=prepare) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 3be4a5d..5752e75 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -1,3 +1,5 @@ +--- + name: Check documentation's hyperlinks on: @@ -11,6 +13,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" - - run: pip install hatch + python-version: "3.x" + - run: pipx install hatch - run: hatch run docs:linkcheck + +... diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5c6ad53 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,58 @@ +--- + +name: Publish delb +on: + push: + tags: ["*"] + + +jobs: + build-and-test: + uses: ./.github/workflows/quality-checks.yml + with: + ref: ${{ env.GITHUB_REF_NAME }} + + pypi: + name: Publish to the cheeseshop + needs: ["build-and-test"] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/delb + permissions: + id-token: write + + steps: + - name: Download package + uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github: + name: Create a Github release + needs: ["build-and-test"] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + - uses: actions/setup-python@v5 + with: + cache: pip + python-version: 3.x + - uses: extractions/setup-just@v2 + - run: pipx install hatch + - run: just docs + - run: pip install panflute pypandoc sphinx + - run: >- + python .github/scripts/make-github-release.py ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +... diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 8d80b35..dd4af15 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -22,34 +22,68 @@ on: jobs: + build: + runs-on: ubuntu-latest + outputs: + python-versions: >- + ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: hynek/build-and-inspect-python-package@v2 + id: baipp + unit-tests: + needs: ["build"] + runs-on: ubuntu-latest + steps: + - uses: extractions/setup-just@v2 + - run: pipx install hatch + - uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 + - uses: actions/setup-python@v5 + with: + cache: pip + python-version: 3.x + - run: just pytest + + compatibility-tests: + needs: ["build", "unit-tests"] runs-on: ubuntu-latest strategy: matrix: - python-version: - - 3.8 # 2024-10 - - 3.9 # 2025-10 - - "3.10" # 2026-10 - - "3.11" # 2027-10 - - "3.12" # 2028-10 + python-version: ${{ fromJson(needs.build.outputs.python-versions) }} steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref || github.ref }} - uses: actions/setup-python@v5 with: + cache: pip python-version: ${{ matrix.python-version }} - uses: extractions/setup-just@v2 - - run: pip install hatch - - run: just pytest + - run: pipx install hatch + - uses: actions/download-artifact@v4 + with: + name: Packages + path: dist + - run: | + rm -r _delb delb + test "$(find dist -name 'delb-*.whl' | wc -l)" -eq 1 + just test-wheel "$(find dist -name 'delb-*.whl')" other-quality-checks: runs-on: ubuntu-latest strategy: matrix: target: - - code-lint - doctest + - lint - mypy steps: - uses: actions/checkout@v4 @@ -57,9 +91,10 @@ jobs: ref: ${{ inputs.ref || github.ref }} - uses: actions/setup-python@v5 with: - python-version: "3.12" + cache: pip + python-version: "3.x" - uses: extractions/setup-just@v2 - - run: pip install hatch + - run: pipx install hatch - run: just ${{ matrix.target }} ... diff --git a/Justfile b/Justfile index e1333c4..a157fc8 100644 --- a/Justfile +++ b/Justfile @@ -3,6 +3,10 @@ default: tests version := `hatch version` +_assert_no_dev_version: + #!/usr/bin/env python3 + if "dev" in "{{version}}": + raise SystemExit(1) # run benchmarks benchmarks: @@ -16,10 +20,6 @@ black: coverage-report: hatch run unit-tests:coverage-report -# code linting with flake8 -code-lint: - hatch run linting:check - # generate Sphinx HTML documentation, including API docs docs: hatch run docs:clean @@ -30,6 +30,11 @@ doctest: hatch run docs:clean hatch run docs:doctest +# code & data linting with flake8 & yamllint +lint: + hatch run linting:check + pipx run yamllint $(find . -name "*.yaml" -or -name "*.yml") + # run static type checks with mypy mypy: hatch run mypy:check @@ -38,18 +43,14 @@ mypy: pytest: hatch run unit-tests:check -# release the current version on github & the PyPI -release: tests - test "{{trim_end_match(version, '-dev')}}" = "{{version}}" || false +# release the current version on github & (transitively) the PyPI +release: _assert_no_dev_version tests {{just_executable()}} -f {{justfile()}} update-citation-file git add CITATION.cff git commit -m "Updates CITATION.cff" git tag {{version}} git push origin main git push origin {{version}} - hatch clean - hatch build - hatch publish # watch, build and serve HTML documentation at 0.0.0.0:8000 serve-docs: @@ -60,7 +61,11 @@ show-docs: docs xdg-open docs/build/html/index.html # run all tests on normalized code -tests: black code-lint mypy pytest doctest +tests: black lint mypy pytest doctest + +# run the testsuite against a wheel (installed from $WHEEL_PATH); intended to run on a CI platform +test-wheel $WHEEL_PATH: + hatch run test-wheel:check # Generates and validates CITATION.cff update-citation-file: diff --git a/pyproject.toml b/pyproject.toml index b95890c..ae61b97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,3 +205,12 @@ coverage-report = """ --cov=_delb --cov=delb \ tests """ + +[tool.hatch.envs.test-wheel] +template = "unit-tests" +skip-install = true +extra-dependencies = [ + "delb @ {root:uri}/{env:WHEEL_PATH}", +] +[tool.hatch.envs.test-wheel.scripts] +check = "python -m pytest tests"