5 The formatter and the linter

Build it yourself. Read the tutorial is the other way to read this part.

This walk goes through rahuldave/cheesecracker on GitHub. This part starts from the code at step-04. The code before this part

If you start at this part, make your clone as Get the project says, and then run this command. It puts in your clone the files that this part starts from. A file that the project deleted before this part stays, so delete it if git check lists it:

git fetch --quiet --tags --force origin; git rm -rq --cached --ignore-unmatch -- :/ && git restore --source step-04 --staged --worktree -- :/
just setup

Code that works can still be hard to read, and a reader can miss a bug in it. A formatter writes the code in one layout. A linter checks the code against rules, such as "no import that nothing uses". ruff is both, and it has the rules of flake8.

5.1 a change in a hurry: the code still works, and reads worse

A change made in a hurry leaves code that works and reads badly. The change adds an import that nothing uses, and takes out spaces and blank lines. It also makes one line 187 characters long, and adds a function that nothing calls.

Apply the patch to src/api-service/api/service.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/f77650600c1176caa9aaee86e6cddfccc43fc69c.diff | git apply --include=src/api-service/api/service.py

Apply the patch to src/api-service/api/utils.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/f77650600c1176caa9aaee86e6cddfccc43fc69c.diff | git apply --include=src/api-service/api/utils.py

Apply both patches. In service.py, read the line import os, the line z=power(x,2)+power(y,2), and the return at the end. In utils.py, read def power(base,exponent): and the new function unused_function().

just test -q

pytest still prints 21 passed. The tests check what the code does, not how it reads.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check f77650600c11

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git fetch --quiet --tags --force origin; git restore --source step-04 --staged -- :/ && git restore --source f77650600c11 --staged --worktree -- :/

What changed

  • src/api-service/api/service.py: an import that nothing uses, missing spaces and blank lines, and a long line. 3 lines added, 5 removed. The file
    • look at z=power(x,2)+power(y,2)
  • src/api-service/api/utils.py: a missing space, extra blank lines, and a function that nothing calls. 6 lines added, 1 removed. The file
    • look at def unused_function():

The patch to src/api-service/api/service.py
@@ -5,6 +5,7 @@ Cheese App API Service
 from fastapi import FastAPI
 from starlette.middleware.cors import CORSMiddleware
 import math
+import os
 from api.utils import power
 
 # Setup FastAPI app
@@ -19,13 +20,10 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
-
 # Routes
 @app.get("/")
 async def get_index():
     return {"message": "Welcome to the Cheese App CI/CD Tutorial!"}
-
-
 @app.get("/health")
 async def health_check():
     return {"status": "healthy"}
@@ -36,6 +34,6 @@ async def calculate_euclidean_distance(x: float = 1, y: float = 2):
     """Calculate the Euclidean distance from origin: sqrt(x^2 + y^2)
     Returns the distance of a point (x, y) from the origin (0, 0)
     """
-    z = power(x, 2) + power(y, 2)
+    z=power(x,2)+power(y,2)
     result = math.sqrt(z)
-    return {"x": x, "y": y, "distance": result}
+    return {"x": x, "y": y, "distance": result, "message": "This is a very long line that exceeds 120 characters and Black will complain about it so we need to fix this formatting issue"}

The patch to src/api-service/api/utils.py
@@ -4,6 +4,11 @@ Used by the Cheese App API Service
 """
 
 
-def power(base, exponent):
+def power(base,exponent):
     """Calculate base raised to the power of exponent"""
     return base**exponent
+
+
+
+def unused_function():
+    pass

This change on GitHub The code after it

5.2 ruff, the formatter and the linter, in the group lint

ruff is a formatter and a linter in one tool, written in Rust by Astral, the makers of uv. ruff format writes the code in the layout of black, the formatter of most Python projects. ruff check checks the rules of flake8 and of many other linters, and can fix some of them. It takes the place of both black and flake8, and runs much faster.

ruff checks the code, and the server does not need it to run. So ruff goes into a group of its own, lint.

uv add --group lint ruff
uv run ruff --version

uv prints the version of ruff, ruff 0.17.1 on the day of the class.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 9662828462ba

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source f77650600c11 --staged -- :/ && git restore --source 9662828462ba --staged --worktree -- :/

What changed

  • pyproject.toml: ruff, in the group lint. 3 lines added. It comes from uv add --group lint ruff. The file
    • look at "ruff>=0.17.1",
  • uv.lock: the exact version of ruff. 29 lines added. It comes from uv add --group lint ruff. The file

The diff that uv add --group lint ruff makes in pyproject.toml
@@ -16,6 +16,9 @@ package = false
 default-groups = "all"
 
 [dependency-groups]
+lint = [
+    "ruff>=0.17.1",
+]
 test = [
     "httpx2>=2.13.1",
     "pytest>=9.1.1",

This change on GitHub The code after it

5.3 ruff's settings: lines of 120 characters, and flake8's rules

By default, ruff allows lines of up to 88 characters, and this project allows 120. Its default rules also leave out some rules of flake8. Two tables in pyproject.toml set both.

Set the values that this change sets in pyproject.toml. Run this command from the top of the project. It reads your file with tomlkit, a library for TOML files. It copies each of these tables and values from the project's file after this change, with their comments, and keeps the rest of your file:

uv run --quiet --no-config --no-project --with tomlkit python - <<'PY'
import copy, pathlib, subprocess, tomlkit
from tomlkit.items import Comment, Table, Whitespace
path = pathlib.Path('pyproject.toml')
doc = tomlkit.parse(path.read_text(encoding='utf-8')) if path.exists() else tomlkit.document()
# The project's file after this change, from your clone
project = tomlkit.parse(subprocess.run(['git', 'show', '7c1670fd52a0:pyproject.toml'], capture_output=True, text=True,
                                        check=True).stdout)
# Each table and value that the change sets, by its keys: each comes from the project's file with its comments
COPY = [
    ('tool', 'ruff'),
]

def body(table):   # the items of a table in their order, with its comment lines
    inner = table if isinstance(table, tomlkit.TOMLDocument) else getattr(table, 'value', None)
    return getattr(inner, 'body', [])

def above(table, key):   # the comment lines just above a key
    found = []
    for name, item in body(table):
        if name is not None and name.key == key:
            return found
        found = found + [item] if isinstance(item, Comment) else found if isinstance(item, Whitespace) else []
    return []

for keys in COPY:
    source, target = project, doc
    for key in keys[:-1]:   # make each table on the way
        source = source[key]
        if key not in target:
            target[key] = tomlkit.table(True)
        target = target[key]
    item = copy.deepcopy(source[keys[-1]])
    if isinstance(item, Table) and not item.as_string().endswith('\n\n'):
        item.add(tomlkit.nl())   # a blank line before the table after it
    if keys[-1] not in target:
        for comment in above(source, keys[-1]):
            target.add(copy.deepcopy(comment))
    target[keys[-1]] = item
path.write_text(tomlkit.dumps(doc), encoding='utf-8')
PY

Read the tables [tool.ruff] and [tool.ruff.lint] of pyproject.toml.

uv run ruff check

ruff finds 2 errors, and fails. F401 is the import of os, which nothing uses. E501 is the line of 187 characters. ruff says that it can fix the first one.

uv run ruff format --check

ruff says 2 files would be reformatted, and fails. --check changes no file.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 7c1670fd52a0

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 9662828462ba --staged -- :/ && git restore --source 7c1670fd52a0 --staged --worktree -- :/

What changed

  • pyproject.toml: the settings of ruff, the length of a line and the rules of the linter. 8 lines added. The file
    • look at select = ["E", "W", "F"]

The patch to pyproject.toml
@@ -30,3 +30,11 @@ test = [
 # The tests are in tests/, and import the package api from src/api-service/
 testpaths = ["tests"]
 pythonpath = ["src/api-service"]
+
+[tool.ruff]
+# The longest line, for the formatter and the linter
+line-length = 120
+
+[tool.ruff.lint]
+# The rules that flake8 checks: pycodestyle's errors (E) and warnings (W), and pyflakes (F)
+select = ["E", "W", "F"]

This change on GitHub The code after it

5.4 just fmt and just lint run ruff

Two recipes give the formatter and the linter short names. Each passes on to ruff any words that you type after it.

Apply the patch to justfile, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/b947593e84b71486902b3ff1c7f9e1458b70e3f7.diff | git apply --include=justfile

Read the recipes fmt and lint at the end of the justfile.

just fmt --check

The recipe fails as ruff format --check did, with 2 files would be reformatted.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check b947593e84b7

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 7c1670fd52a0 --staged -- :/ && git restore --source b947593e84b7 --staged --worktree -- :/

What changed

  • justfile: the recipes fmt and lint. 8 lines added. The file
    • look at uv run ruff check "$@"

The patch to justfile
@@ -44,3 +44,11 @@ stop:
 # Run the tests, and show which lines of the package api they ran. Extra arguments go to pytest: just cov tests/unit
 cov *args:
     uv run pytest --cov=api --cov-report=term-missing "$@"
+
+# Format the code, one way. Extra arguments go to ruff: just fmt --check, just fmt --diff
+fmt *args:
+    uv run ruff format "$@"
+
+# Check the code against the rules in pyproject.toml. Extra arguments go to ruff: just lint --fix
+lint *args:
+    uv run ruff check "$@"

This change on GitHub The code after it

5.5 the formatter fixes the spaces, the blank lines and the line breaks

The formatter writes every file in one layout, so nobody fixes spaces by hand.

just fmt

ruff prints 2 files reformatted. In service.py, z=power(x,2)+power(y,2) now has its spaces, and two blank lines separate the routes. The long return now spreads its dictionary over six lines, one key on each line. In utils.py, the blank lines are two.

just fmt --check

ruff prints 6 files already formatted.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 42642115394d

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source b947593e84b7 --staged -- :/ && git restore --source 42642115394d --staged --worktree -- :/

What changed

  • src/api-service/api/service.py: the spaces, the blank lines, and the long return on six lines. 10 lines added, 2 removed. It comes from just fmt. The file
    • look at z = power(x, 2) + power(y, 2)
  • src/api-service/api/utils.py: the space after the comma, and two blank lines before unused_function(). 1 line added, 2 removed. It comes from just fmt. The file
    • look at def power(base, exponent):

The diff that just fmt makes in src/api-service/api/service.py
@@ -20,10 +20,13 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
+
 # Routes
 @app.get("/")
 async def get_index():
     return {"message": "Welcome to the Cheese App CI/CD Tutorial!"}
+
+
 @app.get("/health")
 async def health_check():
     return {"status": "healthy"}
@@ -34,6 +37,11 @@ async def calculate_euclidean_distance(x: float = 1, y: float = 2):
     """Calculate the Euclidean distance from origin: sqrt(x^2 + y^2)
     Returns the distance of a point (x, y) from the origin (0, 0)
     """
-    z=power(x,2)+power(y,2)
+    z = power(x, 2) + power(y, 2)
     result = math.sqrt(z)
-    return {"x": x, "y": y, "distance": result, "message": "This is a very long line that exceeds 120 characters and Black will complain about it so we need to fix this formatting issue"}
+    return {
+        "x": x,
+        "y": y,
+        "distance": result,
+        "message": "This is a very long line that exceeds 120 characters and Black will complain about it so we need to fix this formatting issue",
+    }

The diff that just fmt makes in src/api-service/api/utils.py
@@ -4,11 +4,10 @@ Used by the Cheese App API Service
 """
 
 
-def power(base,exponent):
+def power(base, exponent):
     """Calculate base raised to the power of exponent"""
     return base**exponent
 
 
-
 def unused_function():
     pass

This change on GitHub The code after it

5.6 the linter removes the import that nothing uses

ruff can remove an import that nothing uses, because the change cannot alter what the code does. The linter still fails after the fix, because one error is left. || true makes the command count as a success all the same.

just lint --fix || true

ruff removes the line import os from service.py, and still reports the long line.

just lint

ruff finds 1 error, E501, and fails. The line of the message is 147 characters long, and no tool can shorten words.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check cfd2ebb050dd

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 42642115394d --staged -- :/ && git restore --source cfd2ebb050dd --staged --worktree -- :/

What changed

  • src/api-service/api/service.py: the import of os, removed. 0 lines added, 1 removed. It comes from just lint --fix || true. The file

The diff that just lint --fix || true makes in src/api-service/api/service.py
@@ -5,7 +5,6 @@ Cheese App API Service
 from fastapi import FastAPI
 from starlette.middleware.cors import CORSMiddleware
 import math
-import os
 from api.utils import power
 
 # Setup FastAPI app

This change on GitHub The code after it

5.7 a shorter line, by hand

A long message is a choice of words, so a person has to shorten it. The new message is short enough for one line.

Apply the patch to src/api-service/api/service.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/693a713a458db01debdd7ebc3b084ed1b0350b08.diff | git apply --include=src/api-service/api/service.py

Apply the patch. It changes the value of "message" in the return at the end of the file.

just lint

ruff prints All checks passed!. Note that ruff never reports unused_function(). No rule of E, W or F looks for a function that nothing calls.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 693a713a458d

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source cfd2ebb050dd --staged -- :/ && git restore --source 693a713a458d --staged --worktree -- :/

What changed

  • src/api-service/api/service.py: a shorter message. 1 line added, 1 removed. The file
    • look at "message": "This is a very long line that exceeds 120 characters blah",

The patch to src/api-service/api/service.py
@@ -42,5 +42,5 @@ async def calculate_euclidean_distance(x: float = 1, y: float = 2):
         "x": x,
         "y": y,
         "distance": result,
-        "message": "This is a very long line that exceeds 120 characters and Black will complain about it so we need to fix this formatting issue",
+        "message": "This is a very long line that exceeds 120 characters blah",
     }

This change on GitHub The code after it

5.8 just check runs every check

The layout, the rules and the tests each have a recipe now. The recipe check runs all three, and stops at the first that fails.

Apply the patch to justfile, from the top of the project:

curl -fsSL https://github.com/rahuldave/cheesecracker/commit/e89e0cfa991257757c887f687087a9c77c093e20.diff | git apply --include=justfile

Read the recipe check at the end of the justfile.

just check

just prints each command, and the last line is 21 passed.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check e89e0cfa9912

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git fetch --quiet --tags --force origin; git restore --source 693a713a458d --staged -- :/ && git restore --source step-05 --staged --worktree -- :/

What changed

  • justfile: the recipe check. 6 lines added. The file
    • look at just fmt --check

The patch to justfile
@@ -52,3 +52,9 @@ fmt *args:
 # Check the code against the rules in pyproject.toml. Extra arguments go to ruff: just lint --fix
 lint *args:
     uv run ruff check "$@"
+
+# Everything a commit must pass: the format, the lint and the tests
+check:
+    just fmt --check
+    just lint
+    just test -q

This change on GitHub

This part ends at the code at step-05. The code after this part