Series · Python Engineering · Chapter 4

Python Engineering (4): Type Hints, Linting, and Code Quality

Add type safety with mypy, enforce style with ruff and black, and automate checks with pre-commit hooks. Make code reviews about logic, not formatting.

Code reviews should be about logic and design, not about whether someone used single quotes or double quotes. Formatting debates are a waste of engineering time. The solution is to let machines handle style and let humans focus on correctness.

This article covers three layers of automated code quality: type hints catch logical errors before runtime, linters catch style violations and common bugs, and pre-commit hooks enforce everything automatically on every commit.


Type Hints: Basic Annotations#

Python is dynamically typed, but since 3.5 it supports optional type annotations. They do not affect runtime behavior. They are metadata that tools like mypy can check.

Type system hierarchy

Primitive Types#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Basic types
name: str = "Alice"
age: int = 30
height: float = 1.75
active: bool = True

# Function annotations
def greet(name: str, excited: bool = False) -> str:
    if excited:
        return f"Hello, {name}!"
    return f"Hello, {name}."

Collection Types#

Since Python 3.9, you can use built-in types directly for generics:

1
2
3
4
5
6
7
8
9
# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (40.7128, -74.0060)
unique_ids: set[int] = {1, 2, 3}

# Nested types
matrix: list[list[int]] = [[1, 2], [3, 4]]
config: dict[str, list[str]] = {"hosts": ["a.com", "b.com"]}

For Python 3.7-3.8, import from typing:

1
2
3
4
from typing import Dict, List, Set, Tuple

names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}

Or use from __future__ import annotations at the top of the file to enable the 3.9+ syntax in older versions.

Optional and Union#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from typing import Optional, Union

# Optional means "this type or None"
def find_user(user_id: int) -> Optional[dict]:
    """Returns user dict or None if not found."""
    ...

# Union means "one of these types"
def process(value: Union[str, int]) -> str:
    return str(value)

# Python 3.10+ shorthand
def find_user(user_id: int) -> dict | None:
    ...

def process(value: str | int) -> str:
    return str(value)

Any, Callable, and Iterator#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from typing import Any, Callable, Iterator

# Any disables type checking for this value
def log(message: Any) -> None:
    print(message)

# Callable[[arg_types], return_type]
def retry(func: Callable[[str], bool], attempts: int = 3) -> bool:
    for _ in range(attempts):
        if func("test"):
            return True
    return False

# Iterator and Generator
def count_up(start: int, end: int) -> Iterator[int]:
    current = start
    while current < end:
        yield current
        current += 1

Type Aliases#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Simple alias
UserId = int
UserRecord = dict[str, Any]

# Complex types benefit from aliases
Headers = dict[str, str]
Callback = Callable[[str, int], bool]
Matrix = list[list[float]]

def fetch(url: str, headers: Headers, on_progress: Callback) -> bytes:
    ...

Generic Types: TypeVar and Protocol#

TypeVar#

When you need a function that works with any type but preserves the relationship:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    """Return the first item. Type of return matches type of items."""
    return items[0]

# mypy knows these types:
x: int = first([1, 2, 3])        # T = int
y: str = first(["a", "b", "c"])  # T = str

Bounded TypeVar#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from typing import TypeVar

# T must be a subclass of int or float
Numeric = TypeVar("Numeric", int, float)

def add(a: Numeric, b: Numeric) -> Numeric:
    return a + b

add(1, 2)       # OK: int
add(1.0, 2.0)   # OK: float
add("a", "b")   # Error: str is not int or float

Protocol (Structural Subtyping)#

Protocol defines an interface by structure, not inheritance. If it has the right methods, it matches:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from typing import Protocol, runtime_checkable


@runtime_checkable
class Readable(Protocol):
    def read(self, size: int = -1) -> bytes:
        ...


def process_stream(source: Readable) -> bytes:
    """Accepts anything with a .read() method."""
    return source.read()


# This works with any object that has .read(), without inheriting Readable
import io
data = process_stream(io.BytesIO(b"hello"))  # OK

TypedDict#

For dictionaries with a known structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from typing import TypedDict


class UserRecord(TypedDict):
    name: str
    age: int
    email: str


class UserRecordPartial(TypedDict, total=False):
    name: str
    age: int
    email: str  # all fields are optional


def create_user(data: UserRecord) -> int:
    # mypy knows data["name"] is str, data["age"] is int
    ...

# OK
create_user({"name": "Alice", "age": 30, "email": "a@b.com"})

# Error: missing "email"
create_user({"name": "Alice", "age": 30})

Type Checking with mypy#

mypy reads your type annotations and reports errors without running the code.

mypy type checking flow

Installation and Basic Usage#

1
2
(.venv) $ pip install mypy
(.venv) $ mypy src/

Strictness Levels#

1
2
3
4
5
6
7
8
# Default: only checks annotated code
(.venv) $ mypy src/

# Strict: requires annotations everywhere, catches more errors
(.venv) $ mypy --strict src/

# Check a single file
(.venv) $ mypy src/my_tool/core.py

Common mypy Errors and Fixes#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Error: Incompatible return value type (got "Optional[str]", expected "str")
def get_name(user_id: int) -> str:
    result = lookup(user_id)  # returns Optional[str]
    return result  # Error!

# Fix: handle the None case
def get_name(user_id: int) -> str:
    result = lookup(user_id)
    if result is None:
        raise ValueError(f"User {user_id} not found")
    return result  # Now mypy knows result is str


# Error: Item "None" of "Optional[dict]" has no attribute "get"
def get_email(user: dict | None) -> str:
    return user.get("email", "")  # Error: user might be None

# Fix: narrow the type
def get_email(user: dict | None) -> str:
    if user is None:
        return ""
    return user.get("email", "")


# Error: Need type annotation for "items"
items = []  # mypy doesn't know the element type

# Fix: annotate
items: list[str] = []


# Error: Argument 1 to "open" has incompatible type "Optional[str]"
def read_file(path: str | None) -> str:
    with open(path) as f:  # Error: path might be None
        return f.read()

# Fix: check first or change the type
def read_file(path: str) -> str:
    with open(path) as f:
        return f.read()

mypy Configuration#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# pyproject.toml

[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true

# Per-module overrides
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true

Gradual Adoption#

You do not need to annotate everything at once. Start with:

  1. New code: always add type hints
  2. Public API functions: annotate return types and parameters
  3. Core modules: add full annotations
  4. Tests: annotate fixtures and helpers, but test functions can be loose

Use # type: ignore[error-code] to suppress specific errors temporarily:

1
result = some_untyped_function()  # type: ignore[no-untyped-call]

Advanced Type Features (Python 3.10+)#

Python’s type system has evolved rapidly. These features solve real problems that basic annotations cannot express.

ParamSpec: Preserving Function Signatures#

When writing decorators, ParamSpec preserves the exact parameter types of the wrapped function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from typing import ParamSpec, TypeVar, Callable
from functools import wraps
import time

P = ParamSpec("P")
R = TypeVar("R")

def timing(func: Callable[P, R]) -> Callable[P, R]:
    """Decorator that logs execution time without losing type info."""
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timing
def fetch_user(user_id: int, include_posts: bool = False) -> dict:
    ...

# mypy knows: fetch_user(user_id=42, include_posts=True) -> dict
# mypy catches: fetch_user("wrong")  # Error: str is not int

Without ParamSpec, decorated functions lose their type signatures and mypy treats them as (*args: Any, **kwargs: Any) -> Any.

TypeVarTuple: Variadic Generics#

TypeVarTuple (Python 3.11+) types functions that accept a variable number of typed arguments:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from typing import TypeVarTuple, Unpack

Ts = TypeVarTuple("Ts")

def first_of(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]:
    return args

# Type checker knows:
result = first_of(1, "hello", 3.14)
# result: tuple[int, str, float]

Practical use case — typed middleware chains:

1
2
3
4
5
6
7
8
from typing import TypeVarTuple, Generic, Unpack, Callable

Ts = TypeVarTuple("Ts")

class Pipeline(Generic[Unpack[Ts]]):
    """Type-safe pipeline where each stage's output feeds the next."""
    def __init__(self, *stages: Unpack[Ts]):
        self.stages = stages

TypeGuard and TypeIs#

Narrow types in conditional branches:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from typing import TypeGuard, TypeIs

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    """After this returns True, mypy knows val is list[str]."""
    return all(isinstance(x, str) for x in val)

def process(data: list[object]):
    if is_string_list(data):
        # mypy knows: data is list[str] here
        print(", ".join(data))  # No error

TypeIs (Python 3.13+) is stricter than TypeGuard — it narrows in both the if and else branches.

@override Decorator (Python 3.12+)#

Explicitly mark methods that override a parent class method. mypy will error if the parent method is renamed or removed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import override

class Animal:
    def speak(self) -> str:
        return "..."

class Dog(Animal):
    @override
    def speak(self) -> str:
        return "Woof"

    @override
    def eat(self) -> None:  # Error: Animal has no method 'eat'
        ...

Pydantic: Runtime Validation from Type Hints#

Pydantic bridges static type hints and runtime validation. Define your data model once, get parsing, validation, and serialization for free.

Basic Models#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pydantic import BaseModel, Field, field_validator
from datetime import datetime

class UserCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: str
    age: int = Field(ge=0, le=150)
    created_at: datetime = Field(default_factory=datetime.now)

    @field_validator("email")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("invalid email format")
        return v.lower()

# Automatic parsing and validation
user = UserCreate(name="Alice", email="ALICE@Example.COM", age=30)
print(user.email)  # "alice@example.com" (transformed)

# Validation error with details
try:
    UserCreate(name="", email="bad", age=-1)
except ValidationError as e:
    print(e.errors())
    # [{'type': 'string_too_short', 'loc': ('name',), ...},
    #  {'type': 'value_error', 'loc': ('email',), ...},
    #  {'type': 'greater_than_equal', 'loc': ('age',), ...}]

Nested Models and Generics#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pydantic import BaseModel
from typing import Generic, TypeVar

T = TypeVar("T")

class Pagination(BaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    per_page: int

    @property
    def has_next(self) -> bool:
        return self.page * self.per_page < self.total

class Order(BaseModel):
    id: int
    product: str
    quantity: int

# Type-safe paginated response
response = Pagination[Order](
    items=[Order(id=1, product="Widget", quantity=5)],
    total=42,
    page=1,
    per_page=10,
)

Pydantic + FastAPI#

FastAPI uses Pydantic models for request/response validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ItemCreate(BaseModel):
    name: str
    price: float = Field(gt=0)
    tags: list[str] = []

class ItemResponse(BaseModel):
    id: int
    name: str
    price: float
    tags: list[str]

@app.post("/items", response_model=ItemResponse)
async def create_item(item: ItemCreate) -> ItemResponse:
    # item is already validated by Pydantic
    saved = await db.save(item.model_dump())
    return ItemResponse(id=saved.id, **item.model_dump())

Pydantic vs dataclasses vs attrs#

FeaturePydanticdataclassesattrs
Runtime validationYes (core feature)NoOptional (validators)
Type coercionYes ("42"42)NoNo
JSON serializationBuilt-inManualManual
PerformanceFast (Rust core in v2)Fastest (no validation)Fast
mypy pluginYesBuilt-in supportYes
Best forAPI boundaries, configInternal data structuresInternal + validation

Guideline: Use Pydantic at system boundaries (API inputs, config files, external data). Use dataclasses for internal value objects where you trust the data.

Dataclass Patterns#

Frozen Dataclasses (Immutable Values)#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from dataclasses import dataclass, field

@dataclass(frozen=True)
class Point:
    x: float
    y: float

    def distance_to(self, other: "Point") -> float:
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

p = Point(1.0, 2.0)
# p.x = 3.0  # Error: FrozenInstanceError

# Hashable → usable as dict keys and in sets
seen: set[Point] = {Point(0, 0), Point(1, 1)}

__post_init__ for Derived Fields#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self):
        self.area = self.width * self.height

r = Rectangle(width=3, height=4)
print(r.area)  # 12.0

Slots (Python 3.10+)#

1
2
3
4
5
6
7
8
@dataclass(slots=True)
class Event:
    name: str
    timestamp: float
    payload: dict

# 20-30% less memory, slightly faster attribute access
# Cannot add new attributes dynamically

Linting with ruff#

Code quality pipeline raw code passing through linter format

ruff is a Python linter written in Rust. It is 10-100x faster than flake8 and replaces flake8, isort, pyflakes, pycodestyle, pydocstyle, and many flake8 plugins in a single tool.

Linting pipeline

Installation and Usage#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(.venv) $ pip install ruff

# Lint
(.venv) $ ruff check src/
src/my_tool/core.py:3:1: F401 [*] `os` imported but unused
src/my_tool/utils.py:15:80: E501 Line too long (92 > 88)
Found 2 errors.
[*] 1 fixable with `--fix`.

# Auto-fix
(.venv) $ ruff check --fix src/
Found 2 errors (1 fixed, 1 remaining).

# Format (replaces black)
(.venv) $ ruff format src/
2 files reformatted.

ruff Configuration#

Ruff vs other linters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# pyproject.toml

[tool.ruff]
target-version = "py311"
line-length = 88

[tool.ruff.lint]
select = [
    "E",     # pycodestyle errors
    "W",     # pycodestyle warnings
    "F",     # pyflakes
    "I",     # isort
    "N",     # pep8-naming
    "UP",    # pyupgrade
    "B",     # flake8-bugbear
    "SIM",   # flake8-simplify
    "C4",    # flake8-comprehensions
    "DTZ",   # flake8-datetimez
    "T20",   # flake8-print (no print in prod code)
    "RET",   # flake8-return
    "PTH",   # flake8-use-pathlib
    "ERA",   # eradicate (commented-out code)
    "RUF",   # ruff-specific rules
]
ignore = [
    "E501",  # line too long (handled by formatter)
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["T20", "S101"]  # allow print and assert in tests

[tool.ruff.lint.isort]
known-first-party = ["my_tool"]

What ruff Catches#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# F401: imported but unused
import os  # <-- ruff removes this

# F841: local variable assigned but never used
def process():
    result = compute()  # <-- ruff flags this
    return None

# B006: mutable default argument
def append_to(item, target=[]):  # <-- Bug! Shared mutable default
    target.append(item)
    return target

# SIM108: use ternary instead of if-else
if condition:
    x = 1
else:
    x = 2
# ruff suggests: x = 1 if condition else 2

# UP035: use PEP 604 union syntax
from typing import Optional  # <-- ruff suggests: str | None
def f(x: Optional[str]): ...

# C4: use dict/list comprehension
dict([(k, v) for k, v in items])  # <-- ruff suggests: {k: v for k, v in items}

Formatting with black#

black is an opinionated code formatter. It makes style decisions for you so you never argue about formatting again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(.venv) $ pip install black

# Check without modifying
(.venv) $ black --check src/
would reformat src/my_tool/core.py
Oh no!
1 file would be reformatted.

# Show what would change
(.venv) $ black --diff src/my_tool/core.py

# Format in place
(.venv) $ black src/
reformatted src/my_tool/core.py
All done!
1 file reformatted.

Note: ruff format is now a drop-in replacement for black, so you can skip installing black separately and just use ruff format.

black Configuration#

1
2
3
4
5
# pyproject.toml

[tool.black]
line-length = 88
target-version = ["py311"]

black has very few options by design. The point is to stop debating. Use the defaults.

Pre-commit Hooks#

Pre-commit runs checks automatically before every git commit. If any check fails, the commit is blocked until you fix the issue.

Pre-commit hooks

Installation#

1
(.venv) $ pip install pre-commit

Configuration#

Create .pre-commit-config.yaml in the project root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.4
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.9.0
    hooks:
      - id: mypy
        additional_dependencies:
          - types-requests
          - pydantic

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-toml
      - id: check-added-large-files
        args: [--maxkb=500]
      - id: debug-statements

Install the Hooks#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
(.venv) $ pre-commit install
pre-commit installed at .git/hooks/pre-commit

# Run against all files (first time)
(.venv) $ pre-commit run --all-files
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed
trailing whitespace......................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
check toml...............................................................Passed
check for added large files..............................................Passed
debug statements.........................................................Passed

Now every git commit runs these checks. If ruff or black reformats a file, the commit fails and you need to git add the reformatted file and commit again.

Skipping Hooks (Emergency Only)#

1
2
3
4
5
# Skip all hooks
$ git commit --no-verify -m "hotfix: emergency patch"

# Skip specific hooks
$ SKIP=mypy git commit -m "WIP: types incomplete"

Use --no-verify sparingly. If you skip hooks regularly, your CI will catch the errors anyway, and you will waste more time fixing them after the fact.

Comparison: ruff vs flake8 vs pylint#

Featureruffflake8pylint
LanguageRustPythonPython
Speed (10k files)~0.1s~30s~120s
Auto-fixYesNo (plugins)No
Import sortingBuilt-in (isort)PluginBuilt-in
FormattingBuilt-in (black-compatible)NoNo
Type checkingNo (use mypy)NoBasic
Plugin ecosystemGrowingHugeBuilt-in
Configurationpyproject.toml.flake8 or setup.cfg.pylintrc
Rules count800+~200 (core)400+
Active developmentVery activeMaintenanceActive

Recommendation: Use ruff. It is faster, fixes issues automatically, and consolidates multiple tools. Add mypy separately for type checking.

Complete pyproject.toml Configuration#

Type hints as blueprint annotations on python code architect

Here is a production-ready tool configuration section:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# pyproject.toml

[tool.ruff]
target-version = "py311"
line-length = 88

[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "C4", "DTZ", "RET", "PTH", "RUF"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["T20", "S101"]

[tool.ruff.lint.isort]
known-first-party = ["my_tool"]

[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --cov=my_tool --cov-report=term-missing"

[tool.coverage.run]
source = ["my_tool"]
branch = true

[tool.coverage.report]
show_missing = true
fail_under = 80

CI Integration: GitHub Actions#

Run all checks in CI so nothing slips through:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"          

      - name: Lint with ruff
        run: ruff check src/ tests/

      - name: Check formatting
        run: ruff format --check src/ tests/

      - name: Type check with mypy
        run: mypy src/

      - name: Run tests
        run: pytest --cov=my_tool --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          file: coverage.xml

What’s Next#

Your code is now type-safe, consistently formatted, and automatically checked on every commit. But Python programs do more than compute; they read files, parse configs, and serialize data in a dozen formats. In the next article, we will master I/O, tackle encoding headaches, and compare every serialization format from JSON to Parquet.

In this series

Python Engineering 8 parts

  1. 01 Python Engineering (1): Environment Setup — pyenv, venv, and Dependency Hell
  2. 02 Python Engineering (2): Project Structure — From Script to Package
  3. 03 Python Engineering (3): Testing — pytest, Fixtures, and the Confidence Loop
  4. 04 Python Engineering (4): Type Hints, Linting, and Code Quality you are here
  5. 05 Python Engineering (5): I/O, Serialization, and Data Formats
  6. 06 Python Engineering (6): Concurrency — Threads, Processes, and asyncio
  7. 07 Python Engineering (7): Packaging — From pip install to PyPI
  8. 08 Python Engineering (8): Performance — Profiling, Caching, and Knowing When to Stop

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub