diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9bd6a45..ab07e9d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -42,23 +42,13 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up python - id: setup-python - uses: actions/setup-python@v7 + - name: Install uv + uses: astral-sh/setup-uv@v10.3.0 with: python-version: '3.13' - - - name: Install and configure poetry - uses: snok/install-poetry@v1 - with: - version: 2.1.4 - virtualenvs-create: true - virtualenvs-in-project: true - installer-parallel: true - - - name: install copier - run: | - python3 -m pip install --user copier>=9 + enable-cache: true + # the root has no lock file; without this the cache key never changes + cache-dependency-glob: "src/pyproject.toml.jinja" - name: create projects with default answers and run check env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f6ed2..c632621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,38 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] -TODO: add at least one Added, Changed, Deprecated, Removed, Fixed or Security section +### Changed + +- **uv replaces poetry as the package and dependency manager** + - the build backend is now `hatchling` with `uv-dynamic-versioning`, which is built on the same `dunamai` engine as `poetry-dynamic-versioning`, so existing tags produce identical version strings + - project metadata moved to the PEP 621 `[project]` table and development dependencies to the PEP 735 `[dependency-groups]` table, which retires the nine deprecation warnings `poetry check` printed on every commit + - the license is declared the PEP 639 way, as an SPDX expression plus `license-files` + - **migrating an existing project** - read this before running `copier update`: + 1. work on a branch with a clean tree + 2. convert a throwaway copy: `git worktree add ../uv-scratch`, then `uvx migrate-to-uv` inside it. Its `pyproject.toml` shows the PEP 440 equivalent of each caret constraint, and its `uv.lock` pins your dependencies to the versions in `poetry.lock`. Do not run it on your branch - the conversion would collide with the template's + 3. `copier update --defaults` + 4. `git rm poetry.lock` and copy `../uv-scratch/uv.lock` into the project - before anything locks, so the old versions are what uv starts from. Also `rm -rf .poetry`: poetry 2 installs its plugins there, git never shows it, and trivy fails `task check` on the lock file inside + 5. in `pyproject.toml`, take the **template's** side of every conflict, then re-declare your own dependencies with `uv add …` and `uv add --group dev …`, copying the constraints from `../uv-scratch/pyproject.toml`. Then delete every remaining `[tool.poetry…]` table, after carrying over what the scratch copy translated for them - sources to `[[tool.uv.index]]`, scripts to `[project.scripts]`, extras to `[project.optional-dependencies]`, plugins to `[project.entry-points]` - because uv ignores them silently + 6. `uv lock` and `uv sync` - a dependency moves off its old version only where the template's constraints require it + 7. `task check`, commit `uv.lock`, then `git worktree remove --force ../uv-scratch` (the scratch copy always holds uncommitted changes) and `git branch -D uv-scratch` + - your own `TaskfileCustom.yaml`, `.gitlab-ci-custom.yml` and `CLAUDE.md` are project owned and are not updated: any `poetry run …`, `poetry version …` or `poetry:install` task dependency in them has to be changed by hand, and the jobs in `.gitlab-ci-custom.yml` inherit `default:`, so they no longer get the removed `before_script` +- uv 0.11.7 or newer is required, enforced by `required-version` in `pyproject.toml` +- `task poetry:install` is now `task uv:install`; every other task keeps its name +- `.python-version` now ships with generic projects too, not only plugin projects, because uv reads it to choose and if necessary download the interpreter +- the source distribution contents are pinned explicitly and are unchanged from before, except that hatchling force-includes `.gitignore` into every sdist and no setting can prevent it - a note at the top of the generated `.gitignore` warns not to put internal names there +- gitlab: the pipeline drops its `before_script` (the eccenca image ships uv and the environment is always `.venv`), caches uv's package cache keyed on `uv.lock`, and publishes with `uv publish` +- github: `astral-sh/setup-uv` replaces both `actions/setup-python` and `snok/install-poetry`; publishing is now `uv build` followed by `uv publish` because `uv publish` has no build flag +- both pipelines set `UV_LOCKED`, so a pipeline whose committed lock file no longer matches `pyproject.toml` fails instead of silently resolving something else + - **this can turn a first pipeline run red** if the lock was not committed alongside a metadata change; `uv lock` and commit the result + +### Removed + +- `poetry.lock` - delete it; `uv.lock` replaces it and belongs in version control +- the `pip` development dependency, a workaround for a scanner finding against the `pip` that poetry installed into every environment. uv installs none +- the `poetry-check` pre-commit hook, which has no uv equivalent. The `poetry-lock` and `poetry-install` hooks become `uv-lock` and `uv-sync` +- the `poetry:install` task, replaced by `uv:install` +- gitlab: `default: before_script` +- `poetry shell`, which uv removed deliberately - use `uv run`, or `source .venv/bin/activate` ## [9.10.0] 2026-10-17 diff --git a/CLAUDE.md b/CLAUDE.md index 61e3b5f..af8d2ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,8 @@ projects and runs *their* checks: 1. `check:generate:cases` renders every test case in `tests/*.yml` into a `_dir` directory at the repository root and git-inits it. -2. `check:validate:cases` runs `poetry update && task check` inside each one. +2. `check:validate:cases` runs `uv sync && task check` inside each one, then + `check:build:case`. The `tests/*.yml` files are copier answer files. `plugin`, `plugin-github-pypi`, `generic-project` and `generic-github-pypi` cover @@ -464,7 +465,8 @@ cd plugin_dir && claude -p "Which files in this repository belong to the templat The answer has to come from `.claude/rules/`. Check the generic case too — it must contain `.claude/settings.json`, `.claude/rules/copier-template.md` and -only the `copier-update` skill, plus `release` when `github_page` is answered, +only the `copier-update` and `template-feedback` skills, plus `release` when +`github_page` is answered, with no plugin material. ### `src/Taskfile.yaml` is deliberately not a copier template @@ -511,7 +513,7 @@ In the badge rows of `README.md`, `src/README.md.jinja` and `src/README-public.md.jinja`, `mypy-shield` points at `https://www.mypy-lang.org/static/mypy_badge.svg`. It is the only badge not served by shields.io, so it renders flat — no gradient, no logo — while -`poetry`, `ruff` and `copier` beside it have both. This reads as an oversight +`uv`, `ruff` and `copier` beside it have both. This reads as an oversight and is not one. That SVG is the badge mypy itself publishes for exactly this use. Replacing it @@ -562,12 +564,12 @@ goes red for reasons unrelated to the change gets disabled or `--force`d, and then it protects nothing — the same reasoning that kept an automated product-name check out of `task check` in report #74. -It also costs more than it looks. The toolchain is otherwise entirely Poetry +It also costs more than it looks. The toolchain is otherwise entirely uv managed, so every generated project, every contributor machine and every runner would need a non-Python binary installed for it. And most of the links in a freshly generated project are the template's own — -`documentation.eccenca.com`, the badge endpoints, `python-poetry.org`, +`documentation.eccenca.com`, the badge endpoints, `docs.astral.sh`, `taskfile.dev`. Those are fixed here, once, for everybody. Checking them again in each of fifty downstream projects re-verifies the template's URLs fifty times over and still says nothing about the one link a plugin author actually @@ -596,3 +598,36 @@ reports `DEP002 'cmem-plugin-base' defined as a dependency but not used`. Three correct signals say the same true thing, so silencing the third one changes nothing a user would notice — while the shell conditional it needs would have to hold on the Windows branch of that task too. + +### The generated `.gitignore` is published inside every sdist + +Hatchling force-includes the VCS ignore file into every source distribution, +from `get_default_build_data()` in `hatchling/builders/sdist.py`, and force +includes bypass `exclude`, `only-include` and `ignore-vcs` alike. No setting +removes it. The warning header at the top of `src/.gitignore` is the +mitigation; the file itself cannot be kept out without changing build backend, +and no other backend offers dunamai's version semantics. + +### `check:trivy` scans the whole repository, `.venv` included + +The scan is deliberately not narrowed to the lock file. Generic projects +rendered from this template regularly carry material beyond the Python package — +a single page frontend with its own dependencies, a Dockerfile — and a +lockfile-only scan would silently skip exactly those. `.venv` is scanned too; it +yields nothing beyond `uv.lock`. + +Two directories are excluded. `.claude` (issue #109), because a git worktree +created there is a second checkout whose lock file would be scanned as if it +were this project's. `.uv-cache`, because it is uv's package cache, not project +content, and the gitlab pipeline keeps it inside the project (GitLab can only +cache paths there). Both live as `--skip-dirs` flags in `check:trivy`, because +such a flag replaces `TRIVY_SKIP_DIRS` rather than adding to it: an exclusion +moved into the environment silently stops working. + +### `poetry shell` has no successor and none is shipped + +uv removed the concept deliberately. The supported equivalents are `uv run`, +which the whole build plan already uses, and `source .venv/bin/activate`, which +the generated README documents. A `shell` task was considered and rejected: a +task runs in a subshell and cannot leave the caller in an activated +environment. diff --git a/README.md b/README.md index 35ba8f3..20e2368 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # cmem-plugin-template [![workflow][build-shield-main]][github-actions] [![workflow][build-shield-develop]][github-actions] [![version][version-shield]][changelog] ![python][python-shield] -[![poetry][poetry-shield]][poetry-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] +[![uv][uv-shield]][uv-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] [![eccenca Corporate Memory][cmem-shield]][cmem] This repository contains a [copier](https://copier.readthedocs.io/) template. @@ -38,7 +38,7 @@ You can use it to bootstrap the following types of project: ## Features -- [Python / poetry](https://python-poetry.org/) project with +- [Python / uv](https://docs.astral.sh/uv/) project with - [pytest](https://www.pytest.org/) (incl. [memray](https://bloomberg.github.io/memray/) + [pytest-dotenv](https://github.com/quiqua/pytest-dotenv) + [code coverage](https://github.com/pytest-dev/pytest-cov)) as the testing framework, - [ruff](https://docs.astral.sh/ruff/) as all-hands linter and formatter, - [mypy](https://mypy-lang.org/) as type checker, @@ -127,7 +127,7 @@ task: Available tasks for this project: * check:trivy: Scan for vulnerabilities using Trivy * format:fix: Format Python files and fix obvious issues * format:fix-unsafe: Format Python files and fix 'unsafe' issues -* poetry:install: Install dependencies managed by Poetry +* uv:install: Install dependencies managed by uv ``` You can extend this task list by creating a file `TaskfileCustom.yaml` in your repository root: @@ -155,10 +155,10 @@ The following tools are needed for local task execution: - Python 3.13.x - [copier](https://copier.readthedocs.io/) (>= v9) for project template rendering and updating - [task](https://taskfile.dev/) (>= v3.29) for running build tasks (make sure to follow the installation instructions to avoid confusion with taskwarrior) -- [poetry](https://python-poetry.org/) (>= v2.1) for packaging and dependency managing (needed plugins such as [poetry-dynamic-versioning](https://github.com/mtkennerly/poetry-dynamic-versioning) are declared with `[tool.poetry.requires-plugins]` and installed automatically) +- [uv](https://docs.astral.sh/uv/) (>= v0.11.7) for packaging and dependency management, with [hatchling](https://hatch.pypa.io/latest/) and [uv-dynamic-versioning](https://github.com/ninoseki/uv-dynamic-versioning) as the build backend, declared in `[build-system]` and installed automatically - [pre-commit](https://pre-commit.com/) (>= v2.20) for managing pre-commit hooks (optional) -Example installation of the requirements with [pipx](https://pipx.pypa.io/) on Ubuntu: +Example installation of the requirements on Ubuntu, using [pipx](https://pipx.pypa.io/) for the Python tools and the official installer for uv: ``` shell-session sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin @@ -166,7 +166,7 @@ python3 -m pip install --user pipx python3 -m pipx ensurepath pipx install copier pipx install pre-commit -pipx install poetry +curl -LsSf https://astral.sh/uv/install.sh | sh ``` ### Integration Tests @@ -276,6 +276,6 @@ OAUTH_GRANT_TYPE="client_credentials" [mypy-shield]: https://www.mypy-lang.org/static/mypy_badge.svg [ruff-link]: https://docs.astral.sh/ruff/ [ruff-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&label=Code%20Style -[poetry-link]: https://python-poetry.org/ -[poetry-shield]: https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json +[uv-link]: https://docs.astral.sh/uv/ +[uv-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json diff --git a/TASKS.md b/TASKS.md index 0c4c31b..ef691d7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -31,33 +31,6 @@ be a real case rather than a theoretical one. question against the grain of `github_page`, and confirm each project gets exactly the pipeline files it can run. -## Migrate the generated `pyproject.toml` to PEP 621 `[project]` metadata - -`src/pyproject.toml.jinja` still declares `name`, `version`, `description`, -`authors`, `license`, `readme`, `keywords` and `classifiers` under -`[tool.poetry]`. Poetry 2.x deprecates all of them in favour of the PEP 621 -`[project]` table. - -This is not latent. Running `poetry check` in a freshly generated project prints -nine deprecation warnings, and `poetry-check` is a pre-commit hook that fires on -every commit touching `pyproject.toml`. Every author using this template is -therefore trained to ignore `poetry check` output, which is exactly where a real -configuration error would eventually appear. - -The migration is fiddly rather than hard, because the project is dynamically -versioned. `poetry check` spells out the required handling for each field: some -move to `[project]` verbatim, while `version`, `readme` and `classifiers` must -either move *and* be declared static, or stay in `[tool.poetry]` and be listed in -`[project.dynamic]`. `poetry-dynamic-versioning` needs `version` to remain -dynamic. - -**Verification:** render each case in `tests/` and run `poetry check` until it is -silent, then `task build` to confirm the dynamic version is still applied to the -resulting artifacts. - -This rewrites the metadata block of every generated project, so it deserves to -be the headline of its own release rather than a line item in a dependency bump. - ## Enforce the documentation conventions in generated plugin projects The conventions for user-facing plugin text - what belongs in the task diff --git a/Taskfile.yaml b/Taskfile.yaml index dfb0df5..e851080 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -25,7 +25,7 @@ tasks: deps: - clean cmds: - - copier copy --vcs-ref {{.TEMPLATE_REVISION}} . new_dir + - uvx --from 'copier>=9' copier copy --vcs-ref {{.TEMPLATE_REVISION}} . new_dir check:generate:case: desc: generate a project with the current version from a TEST_CASE @@ -40,7 +40,7 @@ tasks: - rm -rf {{.TEST_CASE}}_dir - mkdir {{.TEST_CASE}}_dir - cp tests/{{.TEST_CASE}}.yml {{.TEST_CASE}}_dir/.copier-answers.yml - - copier copy --vcs-ref {{.TEMPLATE_REVISION}} --defaults . {{.TEST_CASE}}_dir + - uvx --from 'copier>=9' copier copy --vcs-ref {{.TEMPLATE_REVISION}} --defaults . {{.TEST_CASE}}_dir - git config --global init.defaultBranch develop - cd {{.TEST_CASE}}_dir && git init . - cd {{.TEST_CASE}}_dir && git add . @@ -74,8 +74,42 @@ tasks: cmds: - task: check:hook:case vars: {TEST_CASE: '{{.TEST_CASE}}'} - - cd {{.TEST_CASE}}_dir && poetry update + - cd {{.TEST_CASE}}_dir && uv sync - cd {{.TEST_CASE}}_dir && task check + - task: check:build:case + vars: {TEST_CASE: '{{.TEST_CASE}}'} + + check:build:case: + desc: build a generated project and check the shape of its artifacts + summary: | + Nothing else here runs `task build`, so a change to the sdist include + list - or a hatchling upgrade changing its defaults - would silently ship + tests, pipeline configuration and agent files inside every plugin while + every check stayed green. + internal: true + cmds: + - cd {{.TEST_CASE}}_dir && task build + - cmd: | + cd {{.TEST_CASE}}_dir + sdist=$(ls dist/*.tar.gz) + list=$(tar tzf "$sdist" | sed 's|^[^/]*/||' | sort -u) + for unwanted in tests/ .github/ .claude/; do + if printf '%s\n' "$list" | grep -q "^$unwanted"; then + echo "sdist must not ship $unwanted (found in $sdist)" + exit 1 + fi + done + case "$sdist" in + *-0.0.0.tar.gz) + echo "version was not derived from git: $sdist" + exit 1 + ;; + esac + if grep -q '^-e ' dist/requirements.txt; then + echo "requirements.txt must not contain the project itself" + exit 1 + fi + echo "artifact check ok: $sdist" check:hook:case: desc: smoke test the Stop hook a generated project ships diff --git a/src/.github/workflows/check.yml b/src/.github/workflows/check.yml index 53fc94b..a6baffa 100644 --- a/src/.github/workflows/check.yml +++ b/src/.github/workflows/check.yml @@ -26,6 +26,8 @@ jobs: runs-on: ubuntu-latest concurrency: testing_environment + env: + UV_LOCKED: "1" steps: - name: Check out repository @@ -42,18 +44,11 @@ jobs: - name: Install Task uses: arduino/setup-task@v3 - - name: Set up python - id: setup-python - uses: actions/setup-python@v7 + - name: Install uv + uses: astral-sh/setup-uv@v10.3.0 with: python-version: '3.13' - - - name: Install and configure poetry - uses: snok/install-poetry@v1 - with: - virtualenvs-create: true - virtualenvs-in-project: true - installer-parallel: true + enable-cache: true - name: mypy run: | diff --git a/src/.github/workflows/publish.yml b/src/.github/workflows/publish.yml index e0dbc62..ec4f799 100644 --- a/src/.github/workflows/publish.yml +++ b/src/.github/workflows/publish.yml @@ -22,23 +22,16 @@ jobs: - name: Install Task uses: arduino/setup-task@v3 - - name: Set up python - id: setup-python - uses: actions/setup-python@v7 + - name: Install uv + uses: astral-sh/setup-uv@v10.3.0 with: python-version: '3.13' - - name: Install and configure poetry - uses: snok/install-poetry@v1 - with: - virtualenvs-create: true - virtualenvs-in-project: true - installer-parallel: true + - name: Build Package + run: uv build - name: Publish Package env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - poetry config pypi-token.pypi "$PYPI_TOKEN" - poetry publish --build + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv publish diff --git a/src/.gitignore b/src/.gitignore index e152b44..fe1e35b 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,3 +1,9 @@ +# NOTE: this file is shipped inside the source distribution. Hatchling +# force-includes the VCS ignore file in every sdist and no setting can turn +# that off, so anything written here is published together with the package - +# on PyPI too, if the project publishes there. Keep it free of internal host +# names, customer names and similar. + # https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ @@ -165,3 +171,6 @@ co artifacts .DS_Store .task +# uv's package cache, which the gitlab pipeline keeps inside the project so it +# can be cached between jobs. +.uv-cache diff --git a/src/.pre-commit-config.yaml b/src/.pre-commit-config.yaml index 590e7a8..b26c96a 100644 --- a/src/.pre-commit-config.yaml +++ b/src/.pre-commit-config.yaml @@ -13,27 +13,19 @@ repos: types_or: [python, pyi] pass_filenames: false - - id: poetry-check - name: poetry-check - description: run poetry check to validate config - entry: poetry check + - id: uv-lock + name: uv-lock + description: run uv lock to update the lock file + entry: uv lock language: python pass_filenames: false - files: ^(.*/)?pyproject\.toml$ + files: ^(.*/)?(uv\.lock|pyproject\.toml)$ - - id: poetry-lock - name: poetry-lock - description: run poetry lock to update lock file - entry: poetry lock - language: python - pass_filenames: false - files: ^(.*/)?(poetry\.lock|pyproject\.toml)$ - - - id: poetry-install - name: poetry-install + - id: uv-sync + name: uv-sync description: > - run poetry install to install dependencies from the lock file - entry: poetry install + run uv sync to install dependencies from the lock file + entry: uv sync language: python pass_filenames: false stages: [post-checkout, post-merge] diff --git a/src/{% if project_type == 'plugin' %}.python-version{% endif %}.jinja b/src/.python-version.jinja similarity index 100% rename from src/{% if project_type == 'plugin' %}.python-version{% endif %}.jinja rename to src/.python-version.jinja diff --git a/src/README-public.md.jinja b/src/README-public.md.jinja index 2996260..0be21fb 100644 --- a/src/README-public.md.jinja +++ b/src/README-public.md.jinja @@ -11,12 +11,12 @@ cmemc admin workspace python install {{ package_name }} ``` {%- endif %} {% if github_page -%}[![workflow]({{ github_page }}/actions/workflows/check.yml/badge.svg)]({{ github_page}}/actions){%- endif %} {% if pypi -%}[![pypi version](https://img.shields.io/pypi/v/{{ package_name }})](https://pypi.org/project/{{ package_name }}){%- endif %} {% if pypi -%}[![license](https://img.shields.io/pypi/l/{{ package_name }})](https://pypi.org/project/{{ package_name }}){%- endif %} -[![poetry][poetry-shield]][poetry-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] +[![uv][uv-shield]][uv-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] {% if project_type == 'plugin' -%}[cmem-link]: https://documentation.eccenca.com [cmem-shield]: https://img.shields.io/endpoint?url=https://documentation.eccenca.com/latest/badge.json{%- endif %} -[poetry-link]: https://python-poetry.org/ -[poetry-shield]: https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json +[uv-link]: https://docs.astral.sh/uv/ +[uv-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json [ruff-link]: https://docs.astral.sh/ruff/ [ruff-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&label=Code%20Style [mypy-link]: https://mypy-lang.org/ diff --git a/src/README.md.jinja b/src/README.md.jinja index 26fdebb..b63e90a 100644 --- a/src/README.md.jinja +++ b/src/README.md.jinja @@ -3,11 +3,14 @@ {{ project_description }} {% if project_type == 'plugin' -%}[![eccenca Corporate Memory][cmem-shield]][cmem-link] {%- endif %}{% if github_page -%}[![workflow]({{ github_page }}/actions/workflows/check.yml/badge.svg)]({{ github_page}}/actions){%- endif %} {% if pypi -%}[![pypi version](https://img.shields.io/pypi/v/{{ package_name }})](https://pypi.org/project/{{ package_name}}){%- endif %} {% if pypi -%}[![license](https://img.shields.io/pypi/l/{{ package_name }})](https://pypi.org/project/{{ package_name}}){%- endif %} -[![poetry][poetry-shield]][poetry-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] +[![uv][uv-shield]][uv-link] [![ruff][ruff-shield]][ruff-link] [![mypy][mypy-shield]][mypy-link] [![copier][copier-shield]][copier] ## Development - Run [task](https://taskfile.dev/) to see all major development tasks. +- Commands run inside the project environment with `uv run`; to enter it + instead, activate it with `source .venv/bin/activate` (`.venv\Scripts\activate` on Windows). +- Commit `uv.lock`: both pipelines set `UV_LOCKED`, so the first pipeline fails without it. - Use [pre-commit](https://pre-commit.com/) to avoid errors before commit. - Agent instructions and skills for this project are in `.claude/` - your own instructions belong in `CLAUDE.md`, which is never overwritten. @@ -15,8 +18,8 @@ {% if project_type == 'plugin' -%}[cmem-link]: https://documentation.eccenca.com [cmem-shield]: https://img.shields.io/endpoint?url=https://documentation.eccenca.com/latest/badge.json{%- endif %} -[poetry-link]: https://python-poetry.org/ -[poetry-shield]: https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json +[uv-link]: https://docs.astral.sh/uv/ +[uv-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json [ruff-link]: https://docs.astral.sh/ruff/ [ruff-shield]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&label=Code%20Style [mypy-link]: https://mypy-lang.org/ diff --git a/src/Taskfile.yaml b/src/Taskfile.yaml index 1c75d8c..b3a05fe 100644 --- a/src/Taskfile.yaml +++ b/src/Taskfile.yaml @@ -60,39 +60,39 @@ tasks: directory that exists, and the directory the reports are written to run: once deps: - - poetry:install + - uv:install - package:exists cmds: - mkdir -p {{.DIST_DIR}}/coverage - poetry:install: - desc: Install dependencies managed by Poetry + uv:install: + desc: Install dependencies managed by uv run: once cmds: - - poetry install + - uv sync - # Neither format task depends on `poetry:install`, unlike the check tasks. + # Neither format task depends on `uv:install`, unlike the check tasks. # `format:fix` is what the Claude Code PostToolUse hook runs after every edit, - # and `poetry install` refuses to run while pyproject.toml has been changed - # without the lock file being regenerated - exactly what a dependency bump or - # a template update looks like. Formatting would then quietly stop for the - # rest of the session. On a clone with no virtualenv yet, run `task check` or - # `task poetry:install` first. + # and `uv run` syncs the environment on its own, so formatting keeps working + # right after a dependency bump or a template update without a separate + # install step. That `uv run` also re-locks after a `pyproject.toml` edit, so + # formatting fails until that resolves: offline, with unresolvable + # constraints, or with `UV_LOCKED` exported in the shell. format:fix: desc: Format Python files and fix obvious issues deps: - package:exists cmds: - - poetry run ruff format tests {{.PACKAGE}} - - poetry run ruff check tests {{.PACKAGE}} --fix-only + - uv run ruff format tests {{.PACKAGE}} + - uv run ruff check tests {{.PACKAGE}} --fix-only format:fix-unsafe: desc: Format Python files and fix 'unsafe' issues deps: - package:exists cmds: - - poetry run ruff format tests {{.PACKAGE}} - - poetry run ruff check tests {{.PACKAGE}} --fix-only --unsafe-fixes + - uv run ruff format tests {{.PACKAGE}} + - uv run ruff check tests {{.PACKAGE}} --fix-only --unsafe-fixes clean: desc: Removes dist, *.pyc and some caches @@ -126,21 +126,21 @@ tasks: # --memray is not used on windows - platforms: [windows] cmd: > - poetry run pytest --junitxml={{.JUNIT_FILE}} + uv run pytest --junitxml={{.JUNIT_FILE}} --cov-report term --cov-report xml:{{.COVERAGE_FILE}} --cov-report html:{{.COVERAGE_DIR}} --cov={{.PACKAGE}} --html={{.HTML_FILE}} --self-contained-html - platforms: [darwin, linux] cmd: > - poetry run pytest --memray --junitxml={{.JUNIT_FILE}} + uv run pytest --memray --junitxml={{.JUNIT_FILE}} --cov-report term --cov-report xml:{{.COVERAGE_FILE}} --cov-report html:{{.COVERAGE_DIR}} --cov={{.PACKAGE}} --html={{.HTML_FILE}} --self-contained-html - cmd: > - poetry run genbadge coverage -l + uv run genbadge coverage -l -i {{.COVERAGE_FILE}} -o {{.BADGE_COVERAGE}} - cmd: > - poetry run genbadge tests -l + uv run genbadge tests -l -i {{.JUNIT_FILE}} -o {{.BADGE_TESTS}} vars: BADGE_COVERAGE: ./{{.DIST_DIR}}/badge-coverage.svg @@ -155,7 +155,7 @@ tasks: deps: - prepare cmds: - - poetry run mypy -p tests -p {{.PACKAGE}} --junit-xml {{.JUNIT_FILE}} + - uv run mypy -p tests -p {{.PACKAGE}} --junit-xml {{.JUNIT_FILE}} vars: JUNIT_FILE: ./{{.DIST_DIR}}/junit-mypy.xml @@ -166,13 +166,15 @@ tasks: cmds: # .claude/ is skipped because a git worktree created there is a second # checkout inside the repository, whose lock file is scanned as if it - # were this one - on an older branch, with older findings. + # were this one - on an older branch, with older findings. .uv-cache is + # uv's package cache, which the gitlab pipeline keeps inside the project; + # the flag is harmless where the directory does not exist. - > - poetry run trivy fs + uv run trivy fs --include-dev-deps --scanners vuln --skip-dirs .claude - --skip-files .poetry/plugins/poetry.lock + --skip-dirs .uv-cache --exit-code 1 . @@ -185,7 +187,7 @@ tasks: # a nested copy of one, so the worktree described above would be read as # source and its test imports reported. - > - poetry run deptry . + uv run deptry . --extend-exclude '\.claude' check:ruff: @@ -193,9 +195,9 @@ tasks: deps: - prepare cmds: - - poetry run ruff check --exit-zero tests {{.PACKAGE}} {{.XML_PARAMS}} - - poetry run ruff check --output-format=concise tests {{.PACKAGE}} - - poetry run ruff format --check tests {{.PACKAGE}} + - uv run ruff check --exit-zero tests {{.PACKAGE}} {{.XML_PARAMS}} + - uv run ruff check --output-format=concise tests {{.PACKAGE}} + - uv run ruff format --check tests {{.PACKAGE}} vars: JUNIT_FILE: ./{{.DIST_DIR}}/junit-ruff.xml XML_PARAMS: --output-format junit --output-file {{.JUNIT_FILE}} @@ -208,9 +210,11 @@ tasks: # dist/coverage, and it would race with `clean`, since Task runs deps in # parallel. deps: - - poetry:install + - uv:install - clean cmds: - - poetry build - - poetry export --without=dev -f requirements.txt >dist/requirements.txt + - uv build + - > + uv export --no-dev --no-emit-project + --format requirements-txt -o dist/requirements.txt diff --git a/src/pyproject.toml.jinja b/src/pyproject.toml.jinja index 663b7ed..6656e4e 100644 --- a/src/pyproject.toml.jinja +++ b/src/pyproject.toml.jinja @@ -1,58 +1,71 @@ -[tool.poetry] +[project] name = "{{ package_name }}" -version = "0.0.0" -license = "Apache-2.0" description = "{{ project_description }}" -authors = ["{{ author_name }} <{{ author_mail }}>"] +license = "Apache-2.0" +license-files = ["LICENSE"] +readme = "README-public.md" +authors = [ + { name = "{{ author_name }}", email = "{{ author_mail }}" }, +] +# if you need to change the python version here, change it also in .python-version +requires-python = ">=3.13,<4" +dynamic = ["version"] classifiers = [ - "Development Status :: 4 - Beta",{% if project_type == 'plugin' -%} - "Environment :: Plugins",{%- endif %} + "Development Status :: 4 - Beta",{% if project_type == 'plugin' %} + "Environment :: Plugins",{% endif %} ] -readme = "README-public.md" -{% if project_type == 'plugin' -%}keywords = [ +{% if project_type == 'plugin' -%} +keywords = [ "eccenca Corporate Memory", "plugin" -]{%- endif %} -{% if github_page -%}homepage = "{{ github_page }}"{%- endif %} - -[tool.poetry.requires-plugins] -poetry-plugin-export = "^1.10.0" -poetry-plugin-shell = "^1.0.1" -poetry-dynamic-versioning = "^1.10.0" - -[tool.poetry.dependencies]{% if project_type == 'plugin' -%} -# if you need to change python version here, change it also in .python-version -python = "^3.13"{%- else %} -python = ">=3.13, ^3"{%- endif %} - -{% if project_type == 'plugin' -%}[tool.poetry.dependencies.cmem-plugin-base] -version = "^4.20.0" -allow-prereleases = false +] +dependencies = [ + "cmem-plugin-base>=4.20.0,<5", +] +{%- else -%} +dependencies = [] +{%- endif %} +{% if github_page %} +[project.urls] +Homepage = "{{ github_page }}" +{% endif %} +[dependency-groups] +dev = [{% if project_type == 'plugin' %} + "cmem-cmemc>=24.2.0",{% endif %} + "deptry>=0.25.1,<0.26", + "genbadge[coverage]>=1.1.3,<2", + "mypy>=2.3.0,<3", + "pytest>=9.1.1,<10", + "pytest-cov>=7.1.0,<8", + "pytest-dotenv>=0.5.2,<0.6", + "pytest-html>=4.2.0,<5", + "pytest-memray>=1.10.0,<2 ; platform_system != 'Windows'", + "ruff>=0.16.2,<0.17", + "trivy-py-ecc>=0.76.0.1,<0.77", +] -[tool.poetry.group.dev.dependencies.cmem-cmemc] -version = ">=24.2.0"{%- endif %} +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" -[tool.poetry.group.dev.dependencies] -deptry = "^0.25.1" -genbadge = {extras = ["coverage"], version = "^1.1.3"} -mypy = "^2.3.0" -pip = "^26" -pytest = "^9.1.1" -pytest-cov = "^7.1.0" -pytest-dotenv = "^0.5.2" -pytest-html = "^4.2.0" -pytest-memray = { version = "^1.10.0", markers = "platform_system != 'Windows'" } -ruff = "^0.16.2" -trivy-py-ecc = "^0.76.0.1" +[tool.hatch.version] +source = "uv-dynamic-versioning" -[build-system] -requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] -build-backend = "poetry_dynamic_versioning.backend" +[tool.hatch.build.targets.sdist] +# The sdist ships the package directory and nothing else. hatchling +# force-includes the readme, the license and .gitignore from the metadata, so +# they need no entry here. +only-include = ["{{ package_dir }}"] -[tool.poetry-dynamic-versioning] -enable = true +[tool.uv-dynamic-versioning] vcs = "git" -dirty = true bump = true +dirty = true + +[tool.uv] +required-version = ">=0.11.7" +# uv's cache is aggressive and does not know this project's version is dynamic. +# Without these keys a stale version is served after a new tag. +cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }] [tool.mypy] warn_return_any = true diff --git a/src/{% if not github_page %}.gitlab-ci.yml{% endif %}.jinja b/src/{% if not github_page %}.gitlab-ci.yml{% endif %}.jinja index ed6e22b..8f6dda9 100644 --- a/src/{% if not github_page %}.gitlab-ci.yml{% endif %}.jinja +++ b/src/{% if not github_page %}.gitlab-ci.yml{% endif %}.jinja @@ -3,16 +3,21 @@ default: image: docker-registry.eccenca.com/eccenca-python:v3.13.13 # all jobs can be interrupted in case a new commit is pushed interruptible: true - before_script: - # make sure poetry creates virtual environment as .venv - - poetry config virtualenvs.in-project true cache: - # cache the virtual environment based on the poetry lock file + # cache uv's package cache, keyed on uv.lock key: files: - - poetry.lock + - uv.lock paths: - - .venv + - .uv-cache + after_script: + - uv cache prune --ci + +variables: + # uv's cache has to live inside the project directory to be cacheable here + UV_CACHE_DIR: .uv-cache + # jobs fail instead of silently re-resolving when uv.lock is out of date + UV_LOCKED: "1" stages: - test @@ -108,6 +113,5 @@ pypi: allow_failure: true when: manual script: - - poetry config pypi-token.pypi $PYPI_TOKEN - - poetry publish + - uv publish --token $PYPI_TOKEN {% endif %} diff --git a/src/{% if project_type == 'plugin' %}.tasks-plugin.yml{% endif %}.jinja b/src/{% if project_type == 'plugin' %}.tasks-plugin.yml{% endif %}.jinja index b2e580e..ddc28fa 100644 --- a/src/{% if project_type == 'plugin' %}.tasks-plugin.yml{% endif %}.jinja +++ b/src/{% if project_type == 'plugin' %}.tasks-plugin.yml{% endif %}.jinja @@ -8,12 +8,12 @@ tasks: desc: Install plugin package in Corporate Memory cmds: - task build - - poetry run cmemc admin workspace python install dist/{{package_dir}}*.tar.gz - - poetry run cmemc admin workspace python list-plugins + - uv run cmemc admin workspace python install dist/{{package_dir}}*.tar.gz + - uv run cmemc admin workspace python list-plugins uninstall: desc: Uninstall plugin package in Corporate Memory cmds: - task build - - poetry run cmemc admin workspace python uninstall {{ package_name }} - - poetry run cmemc admin workspace python list-plugins + - uv run cmemc admin workspace python uninstall {{ package_name }} + - uv run cmemc admin workspace python list-plugins diff --git a/src/{{ '.claude' }}/settings.json b/src/{{ '.claude' }}/settings.json index b2845b5..394574e 100644 --- a/src/{{ '.claude' }}/settings.json +++ b/src/{{ '.claude' }}/settings.json @@ -4,10 +4,11 @@ "Bash(task build:*)", "Bash(task check:*)", "Bash(task format:fix:*)", - "Bash(poetry install:*)", - "Bash(poetry run mypy:*)", - "Bash(poetry run pytest:*)", - "Bash(poetry run ruff:*)", + "Bash(uv sync:*)", + "Bash(uv lock:*)", + "Bash(uv run mypy:*)", + "Bash(uv run pytest:*)", + "Bash(uv run ruff:*)", "Bash(gh issue list:*)", "Bash(gh search issues:*)" ] diff --git a/src/{{ '.claude' }}/skills/copier-update/SKILL.md b/src/{{ '.claude' }}/skills/copier-update/SKILL.md index ac66f16..ac91e52 100644 --- a/src/{{ '.claude' }}/skills/copier-update/SKILL.md +++ b/src/{{ '.claude' }}/skills/copier-update/SKILL.md @@ -58,7 +58,10 @@ in the template. `CHANGELOG.md`, `README.md` and `pyproject.toml` are the opposite case: they carry real project content, so keep this project's text and take only the parts -the template actually changed, such as a bumped dependency constraint. +the template actually changed, such as a bumped dependency constraint. A major +release is the exception - it can replace such a file wholesale, in which case +the changelog entry for that release says so and says what to do instead. Read +it before resolving. Example files that were deleted after generation can reappear when the template changes them. Delete them again. @@ -104,7 +107,7 @@ than real debt, and check that first. ## After updating ```bash -poetry update +uv sync task check ``` diff --git a/src/{{ '.claude' }}/skills/{% if github_page %}release{% endif %}/SKILL.md.jinja b/src/{{ '.claude' }}/skills/{% if github_page %}release{% endif %}/SKILL.md.jinja index 17e3c07..5d41813 100644 --- a/src/{{ '.claude' }}/skills/{% if github_page %}release{% endif %}/SKILL.md.jinja +++ b/src/{{ '.claude' }}/skills/{% if github_page %}release{% endif %}/SKILL.md.jinja @@ -5,9 +5,10 @@ description: Cut a release of this project - check the changelog, rename the Unr # Releasing {{ package_name }} -The version is never written into a file. `poetry-dynamic-versioning` derives -it from the git tag, so **the annotated tag is the release**. `pyproject.toml` -keeps `version = "0.0.0"` forever; changing it there does nothing. +The version is never written into a file. `uv-dynamic-versioning` derives it +from the git tag, so **the annotated tag is the release**. `pyproject.toml` +declares `dynamic = ["version"]` and carries no version at all; adding one +there would break the build rather than set the version. {% if pypi -%} Pushing a tag triggers `.github/workflows/publish.yml`, which builds the