Poetry to UV(+ Ruff) Migration

This guide captures the changes necessary to migrate the main development, CI, and container build path from Poetry plus black/isort/flake8/pylint to uv plus ruff.

If you do not want to migrate to Ruff, you can skip the steps related to it and keep your existing formatter/linter.

Moving to UV without changing linting tools

Note

If you want to move to uv from poetry but you don’t want to change your linting from black/isort/flake8/pylint to ruff. Please follow the below steps and ignore the ruff documentation for the rest of this migration guideline. If you are using the AI prompt, note that it might try to migrate to ruff so make sure to review the plan/actions.

Pipeline Machinery allows you to mix and match different templates, however, this migration guideline assumes you are moving to new makefiles (python-uv.mk) which only supports uv`+`ruff and might not work for your case. To keep existing linting tools:

  • Override the python-do-lint make target as below:

-include python-uv.mk

# Make sure to add this after your include
PYTHON_LINE_LENGTH ?= 79## Default value piped to all linting tools
PYTHON_SWITCHES_FOR_ISORT ?=## Custom switches added to isort
PYTHON_SWITCHES_FOR_BLACK ?=## Custom switches added to black
PYTHON_SWITCHES_FOR_FLAKE8 ?=## Custom switches added to flake8
python-do-lint:
  @mkdir -p build/reports;
  $(PYTHON_RUNNER) isort --check-only --profile black --line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_ISORT) $(PYTHON_LINT_TARGET)
  $(PYTHON_RUNNER) black --exclude .+\.ipynb --check --line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_BLACK) $(PYTHON_LINT_TARGET)
  $(PYTHON_RUNNER) flake8 --show-source --statistics --max-line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_FLAKE8) $(PYTHON_LINT_TARGET)
  $(PYTHON_RUNNER) pylint --output-format=parseable,parseable:build/code_analysis.stdout,pylint_junit.JUnitReporter:build/reports/linting-python.xml --max-line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_PYLINT) $(PYTHON_LINT_TARGET)
  @make --no-print-directory join-lint-reports

This will allow you to use the existing makefile support with existing tools without changing linting and building that is compatible with the repository dependencies. build phase is not poetry dependant

For any questions or confusion, please contact the System team, or consult the ska-ser-bar-backend repository for a reference migration.

Note

Treat this as a toolchain migration, not just a dependency change. A useful migration is one where local setup, CI, Docker, helper commands, and contributor documentation all agree on the same path.

Target State

Before calling the migration complete, the repository should have all of the following in place:

  • pyproject.toml uses standard PEP 621 project metadata instead of Poetry-only metadata.

  • uv.lock exists and is committed.

  • local dependency installation uses uv sync.

  • local command execution uses uv run.

  • formatting and linting use Ruff.

  • CI uses uv-aware setup and commands.

  • Docker builds install dependencies with uv.

  • documentation and helper targets no longer direct contributors to Poetry.

Baseline

Before the migration:

  • packaging metadata lived under tool.poetry in pyproject.toml

  • dependency resolution was captured in poetry.lock

  • shared make targets came from .make/python.mk

  • the repo-level runner default was PYTHON_RUNNER ?= poetry run

  • CI included python.gitlab-ci.yml

  • the Docker build installed dependencies with poetry install

  • linting used isort, black, flake8, and pylint

Representative pre-migration references:

  • Makefile included .make/python.mk

  • .gitlab-ci.yml included gitlab-ci/includes/python.gitlab-ci.yml

  • pyproject.toml used [tool.poetry]

Migration Sequence

It is recommended to not perform the migration as a straight shot but to instead break it into smaller, reviewable commits. The steps below describe the recommended order. Each step is designed to be a single reviewable unit of work.

Step 1: Add uv and Ruff

Install uv if it is not already available.

See the uv installation docs for installation methods.

If the repository still uses Poetry metadata, add Ruff in the least disruptive way first:

poetry add --group dev ruff
poetry lock

If the repository already uses uv-compatible metadata, the equivalent is:

uv add --dev ruff
uv lock

Goal of this step:

  • Ruff is installed.

  • Existing workflows still work.

  • The repository is ready for the formatter and linter swap.

Step 2: Convert pyproject.toml to Standard Project Metadata

Move away from Poetry-specific sections such as:

  • [tool.poetry]

  • [tool.poetry.dependencies]

  • [tool.poetry.group.dev.dependencies]

  • [tool.poetry.group.docs.dependencies]

  • [[tool.poetry.source]]

Use standard Python sections such as:

Typical changes:

  • replace tool.poetry with [project]

  • move runtime dependencies into dependencies = []

  • move dev and docs dependencies into [dependency-groups]

  • replace Poetry source definitions with uv index definitions

  • set requires-python explicitly

If the project is packaged, also switch the build backend. For any pure Python projects (which should be the majority of cases), a working replacement is uv-build:

[build-system]
requires = ["uv-build>=0.3.0"]
build-backend = "uv_build.build"

However, if your repository uses any other languages, Hatchling is a suitable alternative:

[build-system]
requires = ["hatchling>=1.27.0"]
build-backend = "hatchling.build"

For a src/ layout, a wheel target often looks like:

[tool.hatch.build.targets.wheel]
packages = ["src/your_package_name"]

Goal of this step:

  • the repository no longer depends on Poetry-only metadata

  • uv can resolve and lock dependencies directly from pyproject.toml

Step 3: Replace poetry.lock with uv.lock

Note

Dependency pinning policy: Pin to the major version for all post-v1 packages (>=X.Y.Z,<(X+1).0.0) because minor and patch releases should not contain breaking changes, while still allowing the project to pick up new features automatically.

For pre-v1 packages (0.x), semver stability guarantees do not apply, so treat them case-by-case: 0.Y.Z is pinned to the minor (<0.(Y+1).0), and 0.0.Z is pinned to the patch (<0.0.(Z+1)).

This mirrors the behaviour of the Poetry caret (^) operator that was used prior to the uv migration.

Once pyproject.toml is uv-compatible, generate the uv lockfile:

uv lock

If the repository uses multiple package indexes, make sure they are declared in pyproject.toml first, otherwise the lock step will not reflect the real installation environment.

After a successful lock:

  • commit uv.lock

  • remove poetry.lock if the repository is fully moving off Poetry

Goal of this step:

  • dependency resolution is reproducible under uv

  • CI and Docker can install from the same lockfile

Step 4: Switch Install and Execution Paths to uv

Every place that used:

poetry install
poetry run <command>

should be reviewed and usually replaced with:

uv sync
uv run <command>

Common replacements:

Before

After

poetry install

uv sync

poetry install --with dev

uv sync --all-groups

poetry run pytest

uv run pytest

poetry run python ...

uv run python ...

poetry run uvicorn ...

uv run uvicorn ...

Typical commands after migration:

uv sync --all-groups
uv run pytest
uv run python path/to/script.py
uv run uvicorn package.app:app --reload

Apply this review to:

  • Makefile targets

  • shell scripts

  • local helper commands

  • token, debug, and admin utilities

  • test runners

Goal of this step:

  • developers no longer need Poetry for local setup or execution

  • helper paths use the same runner as test and CI paths

Step 5: Switch Format and Lint to Ruff

Treat this as a toolchain migration, not just a package addition.

Map the old toolchain to Ruff like this:

  • black -> ruff format

  • isort -> ruff check import rules (rule set I)

  • flake8 -> ruff check (rule set E, F, W)

  • a significant subset of pylint -> ruff check (rule set PL)

Add a minimal Ruff configuration to pyproject.toml:

[tool.ruff]
line-length = 79

[tool.ruff.lint]
select = ["E", "F", "W", "I", "PL"]

Adjust select to match the level of strictness the repository had before. The rule prefixes above cover the same ground as flake8, isort, and the most common pylint checks.

Typical commands:

uv run ruff format .
uv run ruff format --check .
uv run ruff check .

If the repository already has shared lint targets in Make or CI templates, update those targets rather than only documenting raw CLI commands.

Goal of this step:

  • a single formatter and linter entrypoint is in place

  • local and CI lint paths point to Ruff

Step 6: Run Formatting as a Dedicated Change

When switching from black and isort to Ruff, expect a large mechanical diff. Keep that isolated from functional changes.

Typical command:

uv run ruff format src tests

Why isolate this:

  • it makes code review realistic

  • it separates formatting churn from real migration breakage

  • it makes later blame and history easier to interpret

Goal of this step:

  • the codebase is normalized to the new formatter

  • formatter-related failures stop showing up as migration noise

Step 7: Update CI to Use uv-Aware Setup

CI needs the same migration as local development.

Pipeline machinery uses UV. Here are the new CI includes and steps:

 include:
  # Python
- project: 'ska-telescope/templates-repository'
  file: 'gitlab-ci/includes/python-uv.gitlab-ci.yml'

Goal of this step:

  • CI uses the same dependency manager, lockfile, and execution model as local development

Step 8: Update Docker to Install Dependencies with uv

If the repository builds a container image, switch the dependency-install layer to uv.

Please see the example Docker in Container Base images for the recommended pattern.

Goal of this step:

  • container builds are aligned with uv locking and installation

Step 9: Validate and Fix Breakage Exposed by the New Runner

Do not assume the migration is purely mechanical. Toolchain changes often surface pre-existing assumptions.

Typical validation commands:

uv sync --all-groups
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv build

Also validate:

  • local startup

  • token, debug, and admin helper scripts

  • integration test entrypoints

  • Docker builds

Expect a small number of functional fixes. In the repository this guide is based on, the migration exposed a configuration assumption in a token issuance path and required a small code fix.

Goal of this step:

  • the repository is not just converted on paper

  • the repository actually runs, tests, lints, and builds under uv and Ruff

Step 10: Update Docs and Helper Targets Last

The migration is not done until the docs stop teaching the old workflow.

Update:

  • README.md

  • local agent and developer notes

  • onboarding documentation

  • Make targets

  • shell snippets in scripts or wiki pages

Typical replacements:

  • poetry install --with dev -> uv sync --all-groups

  • poetry run uvicorn ... -> uv run uvicorn ...

  • formatter and linter docs -> Ruff commands

Goal of this step:

  • new contributors follow the new path by default

  • there is no accidental regression back to Poetry

Repository-Specific Changes

The following were the main repository-level changes in the migration this guide is based on:

Area

Change

Package metadata

[tool.poetry] was replaced by standard [project] metadata and dependency groups.

Locking

poetry.lock was removed and uv.lock was introduced.

Build backend

poetry-core was replaced with Hatchling.

Package indexes

Poetry sources were replaced by [[tool.uv.index]] entries.

Shared Make support

.make/python.mk was replaced by .make/python-uv.mk.

Default runner

PYTHON_RUNNER ?= poetry run changed to PYTHON_RUNNER ?= uv run.

CI template

GitLab CI switched from the generic Python include to the uv-aware one.

Docker

image builds switched from poetry install to uv sync.

Formatting and linting

the active path moved to ruff format and ruff check.

Formatting Churn Versus Functional Change

Mostly formatting churn:

  • the initial ruff format pass typically reformats many files at once

  • most Python source and test diffs will be line wrapping and whitespace normalization from that one commit

Functional or workflow changes:

  • project metadata and build backend conversion

  • lockfile replacement

  • Make and .make runner changes

  • CI template switch

  • Docker install path switch

  • functional fixes exposed by the new runner (e.g. configuration fallbacks)

Non-Formatting Code Changes

Most Python source changes in a migration will be from ruff format, but switching the runner can also expose pre-existing bugs or configuration assumptions. In the repository this guide is based on, for example:

  • a token issuance function assumed a configuration value was always set

  • the uv-based test path ran in a slightly different environment and exposed the missing fallback

  • a small defensive code change was needed to handle the case gracefully

Why this matters:

  • the uv-based test path can surface configuration assumptions that Poetry masked

  • these are real bugs, not migration artefacts

  • budget time for a small number of functional fixes alongside the migration

Residual Poetry References Clean Up

The migration may still leave follow-up cleanup behind. In the repository this guide is based on, the remaining work was:

Area

Current state

Why it matters

.gitlab-ci.yml

still exported POETRY_VIRTUALENVS_CREATE: "false"

leftover Poetry-specific CI configuration

dev dependency group

still included black, isort, flake8, and pylint

legacy tools remained even though Make lint and format paths used Ruff

What to Keep, What to Remove

You do not need to remove all legacy tools on day one if that makes the migration riskier. A practical path is:

  • switch the active workflow to uv and Ruff first

  • keep legacy packages temporarily if they are still referenced somewhere

  • remove them once CI, docs, and helper paths are clean

Do not leave the repository in an ambiguous state forever. By the end of the migration there should be one documented path.

One-Shot AI Migration Prompt

Full migration prompt (copy/paste into your AI coding agent)

You are migrating this repository from Poetry + black/isort/flake8/pylint
to uv + ruff in one end-to-end pass.

Operate directly on the repository in my workspace and complete the
migration without stopping for confirmation unless you are blocked by
missing credentials or required environment access.

Follow this contract:
- Inputs: existing repository files, current CI setup, Docker build path,
  and contributor docs. Ignore submodules and .make folders. Add the latest version
  of uv and ruff to the repo if they are not already present. This can be found at
  https://pypi.org/project/uv/ and https://pypi.org/project/ruff/.
- Outputs: a fully migrated repo with uv/ruff as the single documented
  and operational path.
- Success criteria: local setup, lint, test, build, CI config, Docker,
  and docs all align on uv/ruff and pass verification.
- Error mode: if a step fails, diagnose root cause, apply focused fixes,
  and re-run checks (up to 3 targeted iterations per failure).

Required migration scope:
0) Before proceeding, ask the user if they would like to migrate to Ruff too. If they do not,
   skip all Ruff-related steps and keep the existing formatter and linter. Make sure to add this after pyton-uv.mk include in the Makefile:
    PYTHON_LINE_LENGTH ?= 79## Default value piped to all linting tools
    PYTHON_SWITCHES_FOR_ISORT ?=## Custom switches added to isort
    PYTHON_SWITCHES_FOR_BLACK ?=## Custom switches added to black
    PYTHON_SWITCHES_FOR_FLAKE8 ?=## Custom switches added to flake8
    python-do-lint:
      @mkdir -p build/reports;
      $(PYTHON_RUNNER) isort --check-only --profile black --line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_ISORT) $(PYTHON_LINT_TARGET)
      $(PYTHON_RUNNER) black --exclude .+\.ipynb --check --line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_BLACK) $(PYTHON_LINT_TARGET)
      $(PYTHON_RUNNER) flake8 --show-source --statistics --max-line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_FLAKE8) $(PYTHON_LINT_TARGET)
      $(PYTHON_RUNNER) pylint --output-format=parseable,parseable:build/code_analysis.stdout,pylint_junit.JUnitReporter:build/reports/linting-python.xml --max-line-length $(PYTHON_LINE_LENGTH) $(PYTHON_SWITCHES_FOR_PYLINT) $(PYTHON_LINT_TARGET)
      @make --no-print-directory join-lint-reports

1) Convert pyproject metadata
   - Replace Poetry-only sections with standard metadata:
     [project], [dependency-groups], [build-system], and [[tool.uv.index]]
     where needed.
   - Set requires-python explicitly.
   - If build backend is poetry-core, switch to a suitable backend
     (prefer uv-build for pure Python projects, Hatchling where needed).

2) Replace lockfile and dependency flow
   - Generate and commit uv.lock.
   - Remove poetry.lock once migration is complete.
   - Ensure installs use uv sync (and uv sync --all-groups where dev/docs
     dependencies are required).

3) Replace command execution path
   - Replace poetry run usages with uv run across Makefiles, scripts,
     helper utilities, docs snippets, and CI jobs.
   - Ensure repo-level runner defaults point to uv run.

4) Migrate lint/format toolchain to Ruff
   - Add/update [tool.ruff] config in pyproject.toml.
   - Map old behaviour:
     black -> ruff format
     isort -> ruff check (I)
     flake8 -> ruff check (E,F,W)
     pylint subset -> ruff check (PL)
   - Update lint/format targets in Make and CI to use Ruff.

5) Keep formatting churn isolated when possible
   - If practical, isolate mass formatting changes from functional
     migration edits; if not practical in one pass, clearly separate
     commits or file groups in your summary.

6) Update CI/CD
   - Remove Poetry bootstrap/config remnants.
   - Switch includes/templates/steps to uv-aware paths.
   - Replace poetry install and poetry run commands with uv equivalents:
   include:
    # Python
     - project: 'ska-telescope/templates-repository'
       file: 'gitlab-ci/includes/python-uv.gitlab-ci.yml'
   - Ensure CI runs uv sync, uv run ruff check, uv run pytest, and uv build
     (or equivalent project build command).

7) Update Docker build path
   - Replace poetry install layers with uv sync-based layers.
   - Use this Dockerfile as a reference:
   .. code:: Dockerfile

    FROM artefact.skao.int/ska-build-python:0.5.0 AS requirements

    WORKDIR /src

    COPY uv.lock pyproject.toml ./

    RUN uv sync --frozen --no-dev --no-install-project

    FROM artefact.skao.int/ska-python:0.2.5

    WORKDIR /src
    ENV VIRTUAL_ENV=/src/.venv
    ENV PATH="$VIRTUAL_ENV/bin:$PATH"

    COPY --from=requirements ${VIRTUAL_ENV} ${VIRTUAL_ENV}
    COPY ./src/my_project ./my_project

    #Add source code to the PYTHONPATH
    #so python is able to find our package
    #when we use it on imports
    ENV PYTHONPATH=${PYTHONPATH}:/src/
      ...
   - Preserve reproducibility (e.g., --frozen and lockfile-driven install)
     and keep runtime image free of unnecessary dev deps.

8) Update documentation and contributor paths
   - Update README, onboarding/contributor docs, and internal how-to pages
     so they teach only uv/ruff.
   - Remove or flag stale Poetry-era instructions.

9) Validate end-to-end and fix breakage
   - Run and pass (or equivalent):
     uv sync --all-groups
     uv run ruff format --check .
     uv run ruff check .
     uv run pytest
     uv build
   - Also validate helper scripts and local startup paths that previously
     relied on poetry run.
   - If migration exposes functional bugs, fix them and include a short
     rationale.

10) Final quality gate report
    - Provide a concise summary with:
      * files changed and why
      * any deferred items and rationale
      * PASS/FAIL for Build, Lint/Typecheck, and Tests
      * explicit confirmation that Poetry is no longer the active path
        in code, CI, Docker, and docs.

Additional constraints:
- Use minimal, targeted edits; avoid unrelated refactors.
- Preserve existing project conventions.
- Do not leave the repo in a dual-path ambiguous state.
- If something cannot be automated, clearly state what is blocked, why,
  and the exact manual follow-up required.