diff --git a/.ci/cliff.toml b/.ci/cliff.toml new file mode 100644 index 0000000..e3d546a --- /dev/null +++ b/.ci/cliff.toml @@ -0,0 +1,65 @@ +# git-cliff configuration for commit-level GitHub release notes. +# https://git-cliff.org/docs/configuration + +[changelog] +# Match GitHub's generated-notes layout while keeping individual commit subjects. +# Deliberately use commit.message rather than remote.pr_title so one PR can produce several bullets. +# GitHub metadata is still used for authors, PR links, and first-time contributors when available. +body = """ +## What's Changed +{% for commit in commits %} + * {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ + {%- endif %} +{%- endfor -%} + +{%- if github -%} +{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + {% raw %}\n{% endraw -%} + ### New Contributors +{%- endif %}\ +{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor -%} +{%- endif -%} + +{% if version %} + {% if previous.version %} + **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} + {% endif %} +{% else -%} + {% raw %}\n{% endraw %} +{% endif %} + +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} +""" +# Remove whitespace introduced by formatting the Tera template for readability. +trim = true +footer = """ + +""" + +[git] +# These two settings include every commit without requiring Conventional Commit syntax. +# Subjects such as "my.module: fix parsing" are useful release notes but are not conventional commits. +conventional_commits = false +filter_unconventional = false +# Treat a multi-line commit as one change rather than turning each body line into another entry. +split_commits = false +# Remove PR suffixes added to some commit subjects because the template adds richer PR links separately. +commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }] +# Keep commits even though this configuration does not classify them with commit_parsers. +filter_commits = false +# Only version-like v-prefixed tags delimit releases. +tag_pattern = "^v[0-9]+.*" +# Process release tags chronologically rather than by graph topology. +topo_order = false +# Present changes in development order, matching GitHub's usual generated-notes order. +sort_commits = "oldest" diff --git a/.ci/prepare-github-release b/.ci/prepare-github-release new file mode 100755 index 0000000..a763f45 --- /dev/null +++ b/.ci/prepare-github-release @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Prepare commit-level release notes and prefill GitHub's release form.""" + +from __future__ import annotations + +import argparse +import re +import webbrowser +from datetime import date +from pathlib import Path +from subprocess import check_call, check_output +from urllib.parse import urlencode + +GITHUB_REMOTE = re.compile(r'github\.com(?::|/)(?P[^/]+/[^/]+?)(?:\.git)?$') +RELEASE_TAG_PATTERN = 'v[0-9]*' + + +def command_output(*command: str, cwd: Path) -> str: + return check_output(command, cwd=cwd, text=True).strip() + + +def git_output(*arguments: str, root: Path) -> str: + return command_output('git', *arguments, cwd=root) + + +def github_repository(*, root: Path) -> str: + remote = git_output('remote', 'get-url', 'origin', root=root) + match = GITHUB_REMOTE.search(remote) + assert match is not None, remote + return match.group('repository') + + +def default_tag(*, previous_tag: str | None) -> str: + today = f'{date.today():%Y%m%d}' + if previous_tag is None: + return f'v0.1.{today}' + + error = f"can't infer the next tag from {previous_tag!r}; rerun with an explicit --tag" + assert previous_tag.startswith('v'), error + components = previous_tag.removeprefix('v').split('.') + assert len(components) == 3, error + major, minor, previous_suffix = components + assert major.isdigit(), error + assert minor.isdigit(), error + assert previous_suffix.isdigit(), error + return f'v{major}.{int(minor) + 1}.{today}' + + +def fetch_latest_release_tag(*, root: Path) -> str | None: + remote_tags = git_output( + 'ls-remote', + '--tags', + '--refs', + '--sort=-version:refname', + 'origin', + RELEASE_TAG_PATTERN, + root=root, + ).splitlines() + if len(remote_tags) == 0: + return None + + _, ref = remote_tags[0].split(maxsplit=1) + tag_prefix = 'refs/tags/' + assert ref.startswith(tag_prefix), ref + check_call(['git', 'fetch', '--no-tags', 'origin', f'{ref}:{ref}'], cwd=root) + return ref.removeprefix(tag_prefix) + + +def fetch_default_branch(*, root: Path) -> tuple[str, str]: + lines = git_output('ls-remote', '--symref', 'origin', 'HEAD', root=root).splitlines() + assert len(lines) == 2, lines + + symbolic_ref_prefix = 'ref: ' + branch_line = lines[0] + assert branch_line.startswith(symbolic_ref_prefix), branch_line + branch_ref, head = branch_line.removeprefix(symbolic_ref_prefix).split() + assert head == 'HEAD', head + + commit, head = lines[1].split() + assert head == 'HEAD', head + + branch_ref_prefix = 'refs/heads/' + assert branch_ref.startswith(branch_ref_prefix), branch_ref + branch = branch_ref.removeprefix(branch_ref_prefix) + remote_ref = f'refs/remotes/origin/{branch}' + check_call(['git', 'fetch', '--no-tags', 'origin', f'+{branch_ref}:{remote_ref}'], cwd=root) + assert git_output('rev-parse', remote_ref, root=root) == commit, remote_ref + return remote_ref, commit + + +def generate_notes(*, commit_range: str, repository: str, root: Path, tag: str) -> str: + # REVIEW: why is this necessary? + # Answer: It isn't needed for public repositories; use GitHub's unauthenticated API. + return check_output( + [ + 'nix', 'run', 'nixpkgs#git-cliff', + '--', + '--config', str(root / '.ci' / 'cliff.toml'), + '--unreleased', + '--tag', tag, + '--github-repo', repository, + commit_range, + ], + cwd=root, + text=True, + ).strip() # fmt: skip + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate commit-level notes and a prefilled GitHub release form without creating a tag or release.", + ) + parser.add_argument( + '--tag', + help='release tag; defaults to v0.1.YYYYMMDD initially or the next minor version thereafter', + ) + parser.add_argument('--title', help="release title; defaults to ': rolling release'") + parser.add_argument('--target', help='Git ref for the release; defaults to the remote default branch') + parser.add_argument('--no-open', action='store_false', dest='open_browser', help='only print the release form URL') + args = parser.parse_args() + + root = Path(__file__).absolute().parent.parent + previous_tag = fetch_latest_release_tag(root=root) + tag = args.tag if args.tag is not None else default_tag(previous_tag=previous_tag) + assert git_output('tag', '--list', tag, root=root) == '', tag + + if args.target is None: + target_ref, target = fetch_default_branch(root=root) + else: + target_ref = args.target + target = git_output('rev-parse', target_ref, root=root) + + commit_range = target if previous_tag is None else f'{previous_tag}..{target}' + commit_count = int(git_output('rev-list', '--count', commit_range, root=root)) + assert commit_count > 0, commit_range + + repository = github_repository(root=root) + title = args.title if args.title is not None else f'{tag}: rolling release' + notes = generate_notes(commit_range=commit_range, repository=repository, root=root, tag=tag) + query = urlencode({'tag': tag, 'target': target, 'title': title, 'body': notes}) + release_url = f'https://github.com/{repository}/releases/new?{query}' + + print(f'Tag: {tag}') + print(f'Title: {title}') + print(f'Target: {target} ({target_ref})') + print() + print(notes) + print() + print('Release form:') + print(release_url) + + if args.open_browser: + assert webbrowser.open(release_url), release_url + + +if __name__ == '__main__': + main() diff --git a/.ci/run b/.ci/run index d7659f5..8609eaf 100755 --- a/.ci/run +++ b/.ci/run @@ -4,7 +4,8 @@ set -eu cd "$(dirname "$0")" cd .. # git root -if ! command -v sudo; then +# projects might need sudo to install OS specific stuff below. +if ! command -v sudo >/dev/null; then # CI or Docker sometimes doesn't have it, so useful to have a dummy function sudo { "$@" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c53a46c..41cf322 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,12 +3,16 @@ name: CI on: push: - branches: '*' + # Deliberate: we want CI on pushed branches without having to open a PR. + branches: '**' tags: 'v[0-9]+.*' # only trigger on 'release' tags for PyPi # Ideally I would put this in the pypi job... but github syntax doesn't allow for regexes there :shrug: # Needed to trigger on others' PRs. # Note that people who fork it need to go to "Actions" tab on their fork and click "I understand my workflows, go ahead and enable them". + # NOTE: together with the push trigger above, same-repo branches with an open PR get double CI runs. + # Keeping both anyway: push covers branches without a PR, this one is needed for fork PRs, + # and the runs aren't identical -- pull_request checks the merge commit against the base branch, push checks the branch head. pull_request: # Needed to trigger workflows manually. @@ -24,13 +28,23 @@ on: - cron: '31 18 * * 5' # run every Friday +permissions: + contents: read # repos created before 2023 have write permissions as default. Protects against compromised actions. + + jobs: build: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, macos-latest] # windows-latest - python-version: ['3.12', '3.13', '3.14'] + platform: + - ubuntu-latest + - macos-latest + # - windows-latest + python-version: + - &minimum_python '3.12' + - '3.13' + - '3.14' # vvv just an example of excluding stuff from matrix # exclude: [{platform: macos-latest, python-version: '3.6'}] @@ -40,12 +54,14 @@ jobs: # continue-on-error: ${{ matrix.platform == 'windows-latest' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: + persist-credentials: false submodules: recursive fetch-depth: 0 # nicer to have all git history when debugging/for tests - - uses: astral-sh/setup-uv@v8.1.0 + # setup-uv doesn't publish floating major/minor tags, so use an exact full tag. + - uses: astral-sh/setup-uv@v9.0.0 with: python-version: ${{ matrix.python-version }} enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key @@ -71,8 +87,8 @@ jobs: runs-on: ubuntu-latest permissions: - # necessary for Trusted Publishing - id-token: write + contents: read # necessary to repeat since 'permissions:' replace global ones, not augment + id-token: write # necessary for Trusted Publishing env: # always deploy merged master to test pypi # always deploy tags to release pypi @@ -83,21 +99,31 @@ jobs: name: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }} url: https://${{ github.ref_type == 'tag' && 'pypi.org' || 'test.pypi.org' }}/project/${{ steps.meta.outputs.pypi_name }}/ steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: + persist-credentials: false submodules: recursive fetch-depth: 0 # pull all commits to correctly infer vcs version - - uses: astral-sh/setup-uv@v8.1.0 + # setup-uv doesn't publish floating major/minor tags, so use an exact full tag. + - uses: astral-sh/setup-uv@v9.0.0 with: - python-version: '3.12' + python-version: *minimum_python enable-cache: false # we don't have lock files during initial CI checkout, so can't use them as cache key - - name: 'release ${{ steps.meta.outputs.pypi_name }} to ${{ env.TARGET }}' + - name: 'release to ${{ env.TARGET }}' run: .ci/release ${{ env.TARGET == 'testpypi' && '--use-test-pypi' || '' }} + # NOTE: name/version are extracted from the built wheel -- build backend is the source of truth for name normalization. + # So this has to run after the release step (which builds the wheel), + # and the release step name above can't include the package name (step names render when the step starts). - id: meta + name: 'release summary' shell: bash run: | - pypi_name=$(unzip -p dist/*.whl '*.dist-info/METADATA' | awk '/^Name:/ {print $2; exit}') + metadata=$(unzip -p dist/*.whl '*.dist-info/METADATA') + pypi_name=$(awk '/^Name:/ {print $2; exit}' <<< "$metadata") + version=$(awk '/^Version:/ {print $2; exit}' <<< "$metadata") echo "pypi_name=$pypi_name" >> "$GITHUB_OUTPUT" + host=$([ "$TARGET" = 'pypi' ] && echo 'pypi.org' || echo 'test.pypi.org') + echo "released [$pypi_name $version](https://$host/project/$pypi_name/$version/) to $TARGET" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index ac83675..802b7ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ - -# Created by https://www.gitignore.io/api/python,emacs -# Edit at https://www.gitignore.io/?templates=python,emacs +# Created by https://www.toptal.com/developers/gitignore/api/vim,emacs,python +# Edit at https://www.toptal.com/developers/gitignore?templates=vim,emacs,python ### Emacs ### # -*- mode: gitignore; -*- @@ -75,7 +74,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -102,8 +100,10 @@ htmlcov/ nosetests.xml coverage.xml *.cover +*.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -126,6 +126,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -136,7 +137,8 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is intended to run in multiple environments; otherwise, check them in: +# .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -145,8 +147,25 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# celery beat schedule file +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid # SageMath parsed files *.sage.py @@ -178,6 +197,50 @@ dmypy.json # Pyre type checker .pyre/ -# End of https://www.gitignore.io/api/python,emacs +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore and can be added to the global gitignore or merged into this file. +# For a more nuclear option (not recommended), you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# End of https://www.toptal.com/developers/gitignore/api/vim,emacs,python + +/private/ -untracked/ +# for now, deliberately ignoring so CI always runs against latest versions +/uv.lock diff --git a/pyproject.toml b/pyproject.toml index 5e02228..0a34f74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ testing = [ ] typecheck = [ "mypy", - "lxml", # for mypy html coverage + "lxml", # not used by default but keeping for convenience of running mypy html coverage "ty", "types-pytz", # optional runtime only dependency @@ -135,7 +135,7 @@ lint.ignore = [ "DTZ", # datetimes checks -- complaining about missing tz and mostly false positives "FIX", # complains about fixmes/todos -- annoying "TD", # complains about todo formatting -- too annoying - "ANN", # missing type annotations? seems way to strict though + "ANN", # missing type annotations? seems way too strict though "EM" , # suggests assigning all exception messages into a variable first... pretty annoying ### too opinionated style checks @@ -145,9 +145,9 @@ lint.ignore = [ "E501", # too long lines "E731", # assigning lambda instead of using def "E741", # Ambiguous variable name: `l` - "E742", # Ambiguous class name: `O + "E742", # Ambiguous class name: `O` "E401", # Multiple imports on one line - "F403", # import *` used; unable to detect undefined names + "F403", # `import *` used; unable to detect undefined names ### ### @@ -169,7 +169,7 @@ lint.ignore = [ ### "PLR0402", # import X.Y as Y -- TODO maybe consider enabling it, but double check - "B009", # calling gettattr with constant attribute -- this is useful to convince mypy + "B009", # calling getattr with constant attribute -- this is useful to convince mypy "B010", # same as above, but setattr "B017", # pytest.raises(Exception) "B023", # seems to result in false positives? @@ -178,18 +178,18 @@ lint.ignore = [ # this is common for click entrypoints (e.g. in __main__), so disable "PIE790", - # a bit too annoying, offers to convert for loops to list comprehension - # , which may heart readability + # a bit too annoying, offers to convert for loops to a list comprehension, + # which may hurt readability "PERF401", - # suggests no using exception in for loops + # suggests not using exceptions in for loops # we do use this technique a lot, plus in 3.11 happy path exception handling is "zero-cost" "PERF203", "RET504", # unnecessary assignment before returning -- that can be useful for readability "RET505", # unnecessary else after return -- can hurt readability - "PLC1901", # suggests using `if string` instead of `strinig == ""` -- dumb. + "PLC1901", # suggests using `if string` instead of `string == ""` -- dumb. "PLW0603", # global variable update.. we usually know why we are doing this "PLW2901", # for loop variable overwritten, usually this is intentional @@ -216,9 +216,9 @@ lint.ignore = [ "INP001", # complains about implicit namespace packages "SIM102", # if statements collapsing, often hurts readability "SIM103", # multiple conditions collapsing, often hurts readability - "SIM105", # suggests using contextlib.suppress instad of try/except -- this wouldn't be mypy friendly + "SIM105", # suggests using contextlib.suppress instead of try/except -- this wouldn't be mypy friendly "SIM108", # suggests using ternary operation instead of if -- hurts readability - "SIM110", # suggests using any(...) instead of for look/return -- hurts readability + "SIM110", # suggests using any(...) instead of for loop/return -- hurts readability "SIM117", # suggests using single with statement instead of nested -- doesn't work in tests "RSE102", # complains about missing parens in exceptions ## @@ -231,7 +231,10 @@ lint.ignore = [ "ISC001", # implicit string concatenation -- we do use it in tests ] - +# NOTE: this is project specific. +# If you need local-only excludes for untracked scratch files, create ignored .ruff.toml: +# extend = "pyproject.toml" +# extend-exclude = ["scratch/**"] extend-exclude = [ "misc/legacy/", # frozen historical source, intentionally outside active checks ] @@ -279,7 +282,7 @@ min_version = "4" env_list = ["ruff", "format", "tests", "benchmark", "mypy", "ty"] # https://github.com/tox-dev/tox/issues/20#issuecomment-247788333 -# Hack to prevent .tox from crapping to the project directory. +# Hack to prevent .tox from crapping into the project directory. work_dir = "{env:TOXWORKDIR_BASE:}{tox_root}{/}.tox" [tool.tox.env_run_base] @@ -298,40 +301,24 @@ pass_env = [ # Generally this is more robust and safer, and prevents weird issues later on. set_env.PYTHONSAFEPATH = "1" -# Uhh. This is relying on https://github.com/tox-dev/tox-uv and things are a bit confusing... -# With this runner: -# runner = "uv-venv-runner" -# First of all, we also want to use: -# package = "uv-editable" -# Otherwise default is "editable", in which tox builds a wheel first for some reason? -# Not sure if that makes much sense. -# However, the main issue is that it does some sort of hacky dependency extraction from pyproject, i.e.: -# tests: install_dependency-groups> .../uv pip install '[export]' 'pytest>=9' ruff -# ...and then installs the actual package: -# tests: install_package> .../uv pip install --reinstall -e /code/packagename -# This is wrong?! -# The first command would install from PyPI, ignoring optional deps if not found. -# What we actually want is something like "uv pip install --group -e .". -# This is the same as this tox issue: https://github.com/tox-dev/tox/issues/3561 - -# Another option is using this: +# Relying on https://github.com/tox-dev/tox-uv for the runners here. +# This template deliberately defaults to the lock runner even though pymplate itself does not use tool.uv.sources. +# Projects based on it may use tool.uv.sources, and keeping one runner avoids changing tox semantics later. runner = "uv-venv-lock-runner" -# However, this requires a lock file to exist. -# This is slightly annoying because I'm not pushing lock files for my projects. -# By default this runner checks that running tox would not change the lock file. -# We can work around that with this setting, so the lock file is created if it -# does not exist, or updated if needed. -# NOTE: in addition, this is the only runner supporting tools.uv.sources. -# Some projects need it. +# The lock runner requires a lock file, which is slightly annoying since we're not tracking lock files (see .gitignore). +# By default it would also check that running tox won't change the lock file. +# With this setting the lock file is created/updated as needed instead: uv_sync_locked = false -# This is a bit annoying. # I'm not sure what happens if two tests running in parallel result in different # lock files, but I guess it is unlikely. -# Another option could be to create the lock file on CI before running things. +# +# If the project doesn't need tool.uv.sources, a fine alternative (no lock file involved at all) is: +# runner = "uv-venv-runner" +# package = "uv-editable" # otherwise the default is "editable", which builds a wheel first for some reason [tool.tox.env.ruff] -skip_install = true +package = "skip" dependency_groups = ["testing"] commands = [ [ @@ -343,7 +330,7 @@ commands = [ [tool.tox.env.format] -skip_install = true +package = "skip" dependency_groups = ["testing"] commands = [ [