Skip to content

One ruff Commit Broke Three Layers at Once

Adityo Guni Waluyo

A harmless-looking ruff sweep touched runtime aliases, TYPE_CHECKING boundaries, isort classification, and the Docker build layout all at once.

TL;DR

A harmless-looking lint commit shipped runtime import moves, type-checking guards, and a new pyproject.toml in one package. Only the last piece broke CI, because setuptools' flat-layout discovery choked on the leftover build directory the Docker install created. The fix: exclude build artifacts in ruff config and clean them within the same RUN instruction.

A lint commit broke the build instead

I hit rebuild on the DemandScope image and watched CI go red. The log was not a lint error. It was setuptools complaining with a specific line: multiple top-level packages discovered in a flat-layout: app, build. I had just merged what looked like a harmless ruff sweep. No logic changes, only imports. My first guess was wrong. I thought isort had reordered something and one moved import would fix it.

The guess missed by two layers. Before this commit the repo had no pyproject.toml at all: no build-system table, no central ruff config. The commit introduced the file and shipped config plus three different kinds of code moves in one package. The sweep touched runtime code, type-checking boundaries, and the build layout, and only the last one crashed the pipeline.

Runtime alias and import boundaries

The first layer was the datetime alias. Python 3.11 added datetime.UTC as an alias for the old timezone object, and ruff rule UP017 rewrites the old pattern into the new one, marking the fix safe unless the expression contains comments[6]. The rewrite is correct here because the new config declares a modern target version. If you still support Python 3.10, check that setting before applying the same fix.

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.domain.models.user import User

class Tenant(Base):
    # at runtime, the import above never executes
    user: Mapped["User"] = relationship()

The isort boundaries deserve their own look before the model imports, because the same config file declares them. Migration files live inside the app package and import alembic, so introspection alone classifies alembic as first-party and rule I001 keeps failing no matter how the lines are sorted[8]. Declaring it third-party explicitly is functional config, not cosmetics.

[tool.ruff]
line-length = 100
target-version = "py312"
extend-exclude = ["build"]

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

[tool.ruff.lint.isort]
known-first-party = ["app"]
# alembic is imported by migration files inside app, but it is not first-party
known-third-party = ["alembic"]

The second layer of code moves was circular imports between ORM models. Both models referenced each other, so the imports moved inside a TYPE_CHECKING guard[7]. The guard executes only during type checking and is False at runtime, which breaks the boot-time cycle while keeping the string annotations resolvable for mypy or pyright. A quick check is importing the model outside the app; if no ImportError appears, the guard works.

# before: the Python 3.10 pattern
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)

# after: the official alias since Python 3.11
from datetime import UTC, datetime, timedelta
now = datetime.now(UTC)

The layer that actually crashed the build

The error at the top of this story came from the build layout. The Dockerfile installed the project into the image, and that install creates a build directory on disk. Nobody cleaned it. On the next cached build the context contained two top-level directories, and setuptools flat-layout discovery refuses to guess when it finds more than one[9]. The app code was fine; the image layout was not.

Two changes close it. First, extend-exclude keeps the linter from ever parsing generated files that survive on the host. Second, the cleanup must live in the same RUN instruction; split across two RUN lines, the artifact is already committed to the previous layer and still poisons the next discovery[9].

RUN pip install --no-cache-dir ".[dev]" && rm -rf build dist *.egg-info
# build artifacts must not pile up in image layers

I kept the datetime and TYPE_CHECKING fixes as they were, because they were correct for the declared Python version and removed real import cycles. What I had missed was that a lint commit is never just about style once it also ships build config. One new file changed how code is parsed, how types are evaluated, and how the package is discovered.

Sources:

[6] Ruff rule UP017 (datetime-timezone-utc)

[7] Python typing spec: directives

[8] Ruff settings (isort first-party/third-party)

[9] Setuptools package discovery (flat-layout)

Related articles