# Structuring a Python repo an agent can navigate

> Source: https://learn-python.com/ai/context/
> Part of Learn Python, free to read.

An agent working on your codebase has a fixed budget of attention. Every file it has to open to answer "where does this belong?" is budget not spent on the change you asked for. Structure that makes the answer obvious is not aesthetic preference any more — it is throughput.

The useful thing is that the properties that help an agent are almost exactly the properties that help a new hire. Nothing here is a compromise made for machines.

## The single highest-leverage rule: boundaries an import can express

An agent decides where to put code by looking at where similar code already lives. If your layout encodes a rule, it follows the rule. If it does not, the agent invents one, and it will invent a different one next week.

```text
src/yourapp/
  core/          pure logic. no I/O, no framework imports, no network.
  adapters/      everything that touches the world: db, http clients, queues, s3
  api/           the web layer. parse -> call core -> serialise. no logic.
  cli/           same, for the terminal
  config.py      settings, read once, passed down
tests/
  core/          fast, offline, no fixtures beyond data
  adapters/      marked integration
```

The rule is expressible as an import constraint, which means you can *enforce* it rather than describe it:

```toml pyproject.toml
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"requests".msg = "Use the http client in adapters/, not requests directly."

[tool.importlinter]
root_package = "yourapp"

[[tool.importlinter.contracts]]
name = "core stays pure"
type = "forbidden"
source_modules = ["yourapp.core"]
forbidden_modules = ["yourapp.adapters", "yourapp.api", "requests", "sqlalchemy"]
```

Now "don't put database calls in core" is a check that fails, not a sentence in a document the agent may or may not weight highly. This is the general pattern worth internalising: **a convention you can enforce is worth ten conventions you can only write down.**

## Files an agent reads first

In rough order of how often they get opened:

1. `AGENTS.md` / `CLAUDE.md` — always, every session
2. `README.md` — usually
3. `pyproject.toml` / `Makefile` — to work out how to run things
4. The test file matching whatever it is editing
5. `__init__.py` of the package it is editing, for the public surface

Make those five accurate and current and you have done most of the work. A stale README is worse than no README: it is confidently wrong context that the agent has no way to distrust.

:::tip The `__init__.py` trick
An `__init__.py` that explicitly re-exports the package's public API tells an agent what is safe to call and what is internal, in one short file. Empty `__init__.py` files force it to read everything to find out.

```python src/yourapp/core/__init__.py
"""Pure domain logic. Nothing in here may perform I/O."""

from .pricing import Cart, Line, price_cart
from .errors import DomainError, InsufficientStock

__all__ = ["Cart", "Line", "price_cart", "DomainError", "InsufficientStock"]
```
:::

## Naming that survives a grep

Agents locate code the same way you do when you are new: search. Names that are searchable are worth real money.

- **Prefer distinctive names over short ones.** `create_subscription` beats `create`. There is one of the first in your codebase and forty of the second.
- **Keep the domain vocabulary consistent.** If the database says `account`, the API says `account`, and the code says `user`, every search returns two thirds of the picture. Pick one and put it in `AGENTS.md`.
- **Name test files after what they test.** `tests/core/test_pricing.py` for `src/yourapp/core/pricing.py`. An agent asked to change `pricing.py` will find the test without being told; if the mapping is arbitrary it will write a new test file next to the old one and you will end up with two.

## Docstrings, but only where they are unguessable

Docstrings on obvious functions are noise. Docstrings that record *why* are the highest-value text in the repo, because "why" is precisely what cannot be recovered from reading the code — by an agent or by you in eight months.

```python
def settle_invoice(invoice: Invoice, at: datetime) -> Settlement:
    """Settle an invoice.

    `at` must be timezone-aware UTC. Settlements are recorded against the
    billing period containing `at`, NOT the invoice date — finance reconciles
    on payment date, and changing this breaks the monthly export in ops-reports.
    """
```

Everything in that second paragraph is invisible in the code and expensive to rediscover. That is the test for whether a docstring earns its place.

## Things that reliably get an agent lost

**Deep inheritance hierarchies.** Following five levels of `super()` to find where a method actually lives consumes an enormous amount of context, and the model frequently gives up and overrides at the wrong level. Composition is easier for everyone.

**Dynamic attribute magic.** `setattr` loops, `__getattr__` fallbacks, string-keyed dispatch dicts built at import time. If static analysis cannot find it, neither can the agent — and neither can your IDE, which should have been the warning.

**Giant modules.** A 3,000-line `utils.py` is read in fragments, so the agent sees a slice and duplicates a function that already exists 900 lines away. Splitting by topic fixes it. If you have a `utils.py`, the things in it belong somewhere with a name.

**Multiple ways to do the same thing.** Two HTTP clients, two config systems, two date helpers. The agent will pick whichever it happened to see first, and your codebase drifts further apart with every change. Delete one.

**Generated code checked in without a marker.** Protobufs, OpenAPI clients, migrations. Put them in an obvious directory and say so in `AGENTS.md`, or you will get careful hand-edits to a file that is overwritten on the next build.

:::warn The `utils.py` tell
If you cannot describe what a module contains in four words, an agent cannot either. That is a reliable signal that the module needs splitting, and it was true before any of this.
:::

## A quick self-test

Pick a change you have not made yet — "add a discount code to the checkout flow" — and ask an agent, with no extra instruction, where the code should go and which tests it would need to change. Do not let it write anything.

If the answer is right, your structure is legible. If it is wrong, the answer tells you exactly which boundary is unclear, which is more useful diagnostic information than any amount of reading your own repo.

## Common questions

### Should I restructure an existing codebase for this?

Not as a project. Do it opportunistically: when you touch an area, leave it with a clearer boundary than you found. The exception is worth making for `utils.py`-style dumping grounds and for anywhere your agent has already got lost twice — those have measurable cost.

### Do bigger context windows make this irrelevant?

No, and this is a common mistake. A larger window means more can be loaded, not that more is attended to well; retrieval quality degrades with irrelevant context in the window. Good structure means less needs to be loaded at all, and that stays valuable regardless of window size.

### Does this conflict with "just ship it" for small projects?

Yes, and for a small project you should just ship it. The rules here start paying at around the point where you can no longer hold the whole repo in your head — which is also, not coincidentally, the point where an agent stops being able to either.
