Skip to content

Python rules

58 rules across 10 categories, enforced by chisel.

app-file · 3 rules

app.py exceeds 50 lines of code

Fix. app.py should contain only create_app() and the lifespan context. Move everything else into the appropriate layer.

Route definition inside app.py

Fix. app.py only creates the app and registers routers. Move this route into routes/ and register it via app.include_router().

app.py cyclomatic complexity exceeds 1

Fix. app.py should contain only create_app() and the lifespan context. Move everything else into the appropriate layer.

complexity · 5 rules

app.py exceeds 50 lines of code

Fix. app.py should contain only create_app() and the lifespan context. Move everything else into the appropriate layer.

Route handler exceeds 20 lines of code

Fix. Route handlers parse input, call the factory, return output — nothing else. Move anything else into a controller or service.

Controller method exceeds 30 lines of code

Fix. Controllers orchestrate — they don’t contain logic. Extract business logic into a service or split concerns across services composed with asyncio.TaskGroup.

Controller method cyclomatic complexity exceeds 3

Fix. Controllers orchestrate — they don’t contain logic. Extract business logic into a service or split concerns across services composed with asyncio.TaskGroup.

Factory cyclomatic complexity exceeds 1

Fix. The factory wires dependencies and makes no decisions. Move the conditional logic into a service method.

concurrency · 1 rule

asyncio.gather() used

Fix. Replace with asyncio.TaskGroup. TaskGroup cancels sibling tasks on failure and propagates exceptions cleanly.

config-startup · 1 rule

os.getenv() called outside config.py

Fix. All environment variables are read once at startup in Config.from_env(). Access config values via the injected config instance.

error-flow · 1 rule

HTTP status code in a domain error class

Fix. Remove the status code from the error class. The mapping from domain error to HTTP status lives exclusively in error_handlers.py.

import-boundary · 8 rules

Layer importing from code it must not depend on

Fix. Models are pure data with no dependencies. If you need logic that uses a service or repository, it belongs in a service method.

Layer importing from a banned layer

Fix. Layer boundary violated. Services never directly import controllers, routes, or other services. Wire dependencies through the factory using Protocol interfaces.

Service importing SQLAlchemy or other banned module

Fix. Services never touch the database. Move the query into a repository method and inject the repository into the service.

fastapi imported outside app.py, routes/, dependencies.py, or error_handlers.py

Fix. FastAPI imports mean HTTP concerns are leaking into the domain. Move the FastAPI-specific code to a route handler or dependency.

sqlalchemy imported outside repositories/ or factory.py

Fix. Services never touch the database. Move the query into a repository method and inject the repository into the service.

sqlalchemy.ext.asyncio imported outside repositories/, factory.py, or dependencies.py

Fix. The session is request-scoped. Create it in dependencies.py, pass it through the factory, and use it inside repositories.

factory.py imported outside routes/ or dependencies.py

Fix. The factory belongs in routes and dependencies only. Thread services through as Protocol-typed parameters everywhere else.

ORM type imported outside repositories/

Fix. ORM types never leave the repository layer. Call _to_domain() inside the repository and return a domain model.

project-structure · 8 rules

Project does not use src layout

Fix. All application code lives under the src layout. Create src// and move all .py files there.

.py file found at project root

Fix. All application code lives under the src layout. Move this file into src//.

.py file found at src/ root

Fix. All application code lives under the src layout. Move this file into src//.

setup.py found in project

Fix. Use pyproject.toml exclusively. Remove setup.py and consolidate dependencies there.

requirements.txt found in project

Fix. Use pyproject.toml exclusively. Remove requirements.txt and consolidate dependencies there.

pyproject.toml not found

Fix. pyproject.toml is required as the single build configuration file.

ORM init.py has no imports

Fix. repositories/orm/init.py must import all ORM models for Alembic autogeneration.

Service or controller has no corresponding test file

Fix. Add a test file under tests/unit/ covering its core invariants.

session · 1 rule

session.execute() called outside repositories/

Fix. Extract the query into a repository method, add it to IYourRepository, and call it from there.

structural · 25 rules

All imports must be at the top of the file

Fix. Use the module-level structlog logger instead. print() has no log level and doesn’t appear in your observability stack.

Import statements inside functions, methods, or blocks

Fix. Import statements inside functions, methods, or blocks are banned — move them to the top of the file.

getattr() or setattr() used in application code

Fix. Add the attribute to the Protocol interface or use an explicit typed constructor. Dynamic attribute access erases the type system.

isinstance() used in application code

Fix. Use match/case for type-based branching. In error handlers the match exc: pattern already handles it. Elsewhere, isinstance checks usually mean logic that belongs on the domain object itself.

class attribute access in application code

Fix. Metaprogramming via class is banned. Use match/case for type-based branching instead.

Percent (%) string interpolation used

Fix. Use f-strings for application strings. For logger calls use structured keyword arguments: logger.info(‘message’, key=value).

f-string passed to a logger call

Fix. Pass context as keyword arguments, not interpolated strings. Replace logger.info(f’Created {x}’) with logger.info(‘Created item’, id=x).

print() called in src/

Fix. Use the module-level structlog logger instead. print() has no log level and doesn’t appear in your observability stack.

Class in services/, controllers/, or repositories/ is not a @dataclass

Fix. Add @dataclass(slots=True) and declare dependencies as typed fields. This makes dependencies explicit and injectable.

@dataclass without slots=True

Fix. Dataclasses must use slots=True for performance and memory efficiency.

@dataclass in models/ without frozen=True

Fix. Value objects and output models in models/ must use frozen=True to ensure immutability.

@dataclass with zero methods in services/, controllers/, or repositories/

Fix. This is a model, not a service/controller/repository. Move it to models/.

logger defined as a dataclass field

Fix. The logger is a module-level constant, not a dependency. Move it outside the class: logger = structlog.getLogger(name).

AppError raised directly

Fix. Raise a named subclass instead: raise NotFoundError(…). Define new errors in errors.py if needed.

HTTP status code in a domain error class

Fix. Remove the status code from the error class. The mapping from domain error to HTTP status lives exclusively in error_handlers.py.

try/except inside a route handler

Fix. Route handlers don’t catch exceptions — error_handlers.py does. Remove the try/except and let the exception propagate.

@staticmethod on AppFactory or CheckerFactory

Fix. The factory is instantiated per-request and carries session and user context. Make it a regular instance method.

Conditional logic in AppFactory or CheckerFactory

Fix. The factory wires dependencies and makes no decisions. Move the conditional logic into a service method.

Bare Column() used instead of Mapped[T] in ORM models

Fix. Use Mapped[T] for ORM column types instead of bare Column().

Service implementation has no corresponding Protocol

Fix. Define an IYourService Protocol in the same file. Controllers and the factory depend on the interface, not the concrete class.

HTTPException imported outside error_handlers.py

Fix. HTTPExceptions must only appear in error_handlers.py. Raise a domain error from errors.py instead and map it to HTTP status in the error handler.

match/case used outside error_handlers.py

Fix. match/case is only allowed in error_handlers.py for exception type branching. Use if/elif everywhere else.

Top-level standalone function in services/

Fix. Services must be @dataclass classes, not standalone functions. Move this function into a service class.

status imported from fastapi/starlette outside error_handlers.py

Fix. HTTP status codes must only appear in error_handlers.py. Raise a domain error and map it to HTTP status in the error handler.

Concrete service class imported outside factory.py or controllers/

Fix. Factories and controllers assemble concrete implementations. Import the Protocol interface everywhere else.

test-structure · 5 rules

Test file outside tests/unit/, tests/integration/, or tests/e2e/

Fix. Move into the correct directory. Unit tests in tests/unit/, repository tests in tests/integration/, full-stack tests in tests/e2e/.

More than one assert in a test function

Fix. Split into separate test functions, one per assertion. Name each after the invariant it proves: test_cannot_X, test_returns_Y_when_Z.

Test name does not describe an invariant

Fix. Name the test after the invariant it proves: test_cannot_X, test_returns_Y_when_Z, test_detects_X, test_allows_X_under_Y.

@pytest.mark.skip without a reason

Fix. Add reason= explaining why this test is skipped and when it should be re-enabled.

TestClient, uvicorn, or httpx imported in unit/integration tests

Fix. Inject fakes and call the service or controller directly. The factory pattern exists to make this possible without spinning up the app.