---
name: python-code-style
source: https://app.decimal.ai/s/python-code-style@1/SKILL.md
source_sha256: 69d893c20a57
---

# Python House Style

## Contract

Enforces this team's ARBITRARY house values for ruff, mypy, Google-style docstrings,
typing, and imports. Apply whenever you write a `pyproject.toml` tooling section, write
or edit a docstring, or write a type signature. These values are deliberate house
choices — do NOT substitute the tool defaults (ruff defaults to 88, single quotes, a
minimal rule set; the docstring section order is unspecified; `typing.Optional` is the
common default). All of those are wrong here.

## Rules (the complete spec)

### ruff — `[tool.ruff]`
- `line-length = 120`. NOT the 88 default, NOT 79, NOT 100.
- `target-version = "py312"`.

### ruff — `[tool.ruff.lint]`
- `select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]` — exactly these eight codes, in
  this order. The four that base models routinely omit are `B` (bugbear), `C4`
  (comprehensions), `UP` (pyupgrade), `SIM` (simplify) — always include all four.
- `ignore = ["E501"]` — line length is the formatter's job, so the linter must not also
  flag it.

### ruff — `[tool.ruff.format]`
- `quote-style = "double"`.
- `indent-style = "space"`.

### mypy — `[tool.mypy]`
- `python_version = "3.12"`.
- `strict = true`.
- `warn_return_any = true`.
- `warn_unused_ignores = true`.

### mypy — overrides
- Add a `[[tool.mypy.overrides]]` block with `module = "tests.*"` and
  `disallow_untyped_defs = false`. Test code is exempt from the required-annotation rule;
  application code is not.

### Docstrings — Google style
- First line: a one-line summary that ends in a period.
- Include ONLY the sections that apply, and when present always in this EXACT order:
  `Args:`, then `Returns:`, then `Raises:`, then `Example:`. Never reorder them (a
  `Returns:` before `Args:`, or `Raises:` before `Returns:`, is wrong).
- Under `Args:`, document every parameter by name.
- Under `Raises:`, document every exception the function can raise.
- The `Example:` section uses `>>>` doctest prompts, and comes last.

### Typing
- Built-in generics only: `list[...]`, `dict[...]`, `tuple[...]`, `set[...]`. Never the
  capitalized `List`/`Dict`/`Tuple`/`Set` from `typing`.
- Spell optionals as `X | None`. Never `Optional[X]`. Never `Union[X, None]`.

### Imports
- Absolute imports only: `from myproject.models import User`. Never relative
  (`from ..models import User`, `from .utils import x`).
- Group in order with a blank line between groups: standard library, then third-party,
  then local.

## Worked examples (before → after)

### ruff line-length
BEFORE (base default):
```toml
[tool.ruff]
line-length = 88
```
AFTER (house):
```toml
[tool.ruff]
line-length = 120
target-version = "py312"
```

### ruff select set
BEFORE (base picks a minimal set):
```toml
[tool.ruff.lint]
select = ["E", "F"]
```
AFTER (house — all eight, with E501 ignored):
```toml
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
ignore = ["E501"]
```

### ruff formatter quote style
BEFORE (base omits format table, leaving the default single-quote behavior implicit):
```toml
# (no [tool.ruff.format] block)
```
AFTER:
```toml
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
```

### mypy strictness + tests override
BEFORE (base turns on strict but forgets to exempt tests, so test files must be fully typed):
```toml
[tool.mypy]
strict = true
```
AFTER:
```toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true

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

### Docstring section order
BEFORE (base writes a NumPy/Sphinx mix, or puts `Returns` before `Args`):
```python
def get_user(user_id: str) -> User:
    """Get a user.

    :param user_id: the id
    :returns: the user
    """
```
AFTER (Google order: Args → Returns → Raises → Example):
```python
def get_user(user_id: str) -> User:
    """Retrieve a user by their unique identifier.

    Args:
        user_id: The user's unique identifier.

    Returns:
        The matching User.

    Raises:
        UserNotFoundError: If no user has that id.

    Example:
        >>> get_user("u_123")
        User(id='u_123')
    """
```

### Optional return type
BEFORE (base default):
```python
from typing import Optional

def find_user(email: str) -> Optional[User]:
    ...
```
AFTER:
```python
def find_user(email: str) -> User | None:
    ...
```

### Built-in generics
BEFORE:
```python
from typing import Dict, List

def merge(records: List[Dict[str, object]]) -> List[Dict[str, object]]:
    ...
```
AFTER:
```python
def merge(records: list[dict[str, object]]) -> list[dict[str, object]]:
    ...
```

### Imports
BEFORE (base reaches for a relative import inside a package):
```python
from ..models import User
from .utils import retry
```
AFTER:
```python
from myproject.models import User
from myproject.utils import retry
```

## Edge cases & exceptions

- **Acronyms in names** stay uppercase in PascalCase classes: `HTTPClient`, not
  `HttpClient`; `APIError`, not `ApiError`. Functions/variables stay snake_case.
- **A function that returns `None`** (a pure side-effect) omits the `Returns:` section
  entirely — do not write `Returns: None`. Order is then just `Args:` (and `Raises:` /
  `Example:` if they apply).
- **A function with no parameters** omits `Args:`. Never write an empty `Args:` block.
- **`__init__`** documents its parameters under `Args:` and never documents a return
  value (it returns `None` by construction).
- **Tests are the ONLY mypy exemption.** Do not broaden the override to `src.*` or any
  application module — only `tests.*` gets `disallow_untyped_defs = false`.
- **E501 is the only code in `ignore`.** Do not also ignore `E`, `F`, or anything else to
  silence noise; fix the code instead.
- **`tuple`/`set` follow the same generics rule** as `list`/`dict`: `tuple[int, str]`,
  `set[str]`, never `Tuple`/`Set`.
- **Re-export modules** (`__init__.py` aggregating package symbols) still use absolute
  imports — a package re-export is not a license for a relative import.

## Do / Don't

- DON'T set `line-length = 88` (the default). DO set `line-length = 120`.
- DON'T ship `select = ["E", "F"]`. DO include `B`, `C4`, `UP`, `SIM` as well.
- DON'T forget `ignore = ["E501"]`. DO add it whenever the formatter owns wrapping.
- DON'T leave `quote-style` unset. DO set `quote-style = "double"`.
- DON'T enable `strict` without the tests override. DO add the `tests.*` override.
- DON'T order docstring sections Returns-before-Args. DO use Args → Returns → Raises → Example.
- DON'T write `Optional[X]` or `Union[X, None]`. DO write `X | None`.
- DON'T import `List`/`Dict` from typing. DO use `list`/`dict`.
- DON'T use relative imports. DO use absolute imports from the package root.

## Common mistakes (the base model's wrong defaults)

1. Emitting `line-length = 88` because that is ruff's documented default.
2. Selecting only `["E", "F"]` (or `["E", "W", "F"]`) and dropping `B`/`C4`/`UP`/`SIM`.
3. Omitting `ignore = ["E501"]`, so the linter double-flags long lines.
4. Leaving out `[tool.ruff.format]` entirely, so quote style is unset.
5. Turning on `strict = true` but forgetting the `tests.*` override.
6. Writing reStructuredText/Sphinx (`:param:`, `:returns:`) or NumPy-style docstrings
   instead of Google style.
7. Putting `Returns:` before `Args:`, or omitting `Raises:` for a function that clearly
   throws.
8. Defaulting to `Optional[X]` and `List`/`Dict` from typing instead of `X | None` and
   `list`/`dict`.
9. Using a relative import inside a package.

## Quick checklist

- [ ] `line-length = 120`, `target-version = "py312"`
- [ ] `select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]`, `ignore = ["E501"]`
- [ ] `[tool.ruff.format]` → `quote-style = "double"`, `indent-style = "space"`
- [ ] mypy `strict = true` + `warn_return_any` + `warn_unused_ignores`
- [ ] `[[tool.mypy.overrides]]` `module = "tests.*"`, `disallow_untyped_defs = false`
- [ ] docstring summary ends in a period; sections Args → Returns → Raises → Example
- [ ] `Example:` uses `>>>`; `Args:` covers every param; `Raises:` covers every exception
- [ ] `X | None` not `Optional`; `list`/`dict` not `List`/`Dict`; absolute imports only
