Python rules
58 rules across 10 categories, enforced by chisel.
App File
Section titled “App File”app-file · 3 rules
app-file:app-loc-limit
Section titled “app-file:app-loc-limit”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.
app-file:route-in-app
Section titled “app-file:route-in-app”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-file:app-complexity-limit
Section titled “app-file:app-complexity-limit”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
Section titled “Complexity”complexity · 5 rules
complexity:app-loc-limit
Section titled “complexity:app-loc-limit”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.
complexity:route-loc-limit
Section titled “complexity:route-loc-limit”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.
complexity:controller-loc-limit
Section titled “complexity:controller-loc-limit”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.
complexity:controller-complexity-limit
Section titled “complexity:controller-complexity-limit”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.
complexity:factory-complexity-limit
Section titled “complexity:factory-complexity-limit”Factory cyclomatic complexity exceeds 1
Fix. The factory wires dependencies and makes no decisions. Move the conditional logic into a service method.
Concurrency
Section titled “Concurrency”concurrency · 1 rule
concurrency:asyncio-gather-banned
Section titled “concurrency:asyncio-gather-banned”asyncio.gather() used
Fix. Replace with asyncio.TaskGroup. TaskGroup cancels sibling tasks on failure and propagates exceptions cleanly.
Config Startup
Section titled “Config Startup”config-startup · 1 rule
config-startup:getenv-outside-config
Section titled “config-startup:getenv-outside-config”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
Section titled “Error Flow”error-flow · 1 rule
error-flow:http-in-error
Section titled “error-flow:http-in-error”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
Section titled “Import Boundary”import-boundary · 8 rules
import-boundary:layer-no-internal-imports
Section titled “import-boundary:layer-no-internal-imports”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.
import-boundary:layer-banned-import
Section titled “import-boundary:layer-banned-import”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.
import-boundary:banned-module
Section titled “import-boundary:banned-module”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.
import-boundary:fastapi-location
Section titled “import-boundary:fastapi-location”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.
import-boundary:sqlalchemy-location
Section titled “import-boundary:sqlalchemy-location”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.
import-boundary:async-session-location
Section titled “import-boundary:async-session-location”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.
import-boundary:factory-import-location
Section titled “import-boundary:factory-import-location”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.
import-boundary:orm-leak
Section titled “import-boundary:orm-leak”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
Section titled “Project Structure”project-structure · 8 rules
project-structure:src-layout-missing
Section titled “project-structure:src-layout-missing”Project does not use src layout
Fix. All application code lives under the src layout. Create src/
project-structure:root-py-file
Section titled “project-structure:root-py-file”.py file found at project root
Fix. All application code lives under the src layout. Move this file into src/
project-structure:src-root-py-file
Section titled “project-structure:src-root-py-file”.py file found at src/ root
Fix. All application code lives under the src layout. Move this file into src/
project-structure:setup-py-banned
Section titled “project-structure:setup-py-banned”setup.py found in project
Fix. Use pyproject.toml exclusively. Remove setup.py and consolidate dependencies there.
project-structure:requirements-txt-banned
Section titled “project-structure:requirements-txt-banned”requirements.txt found in project
Fix. Use pyproject.toml exclusively. Remove requirements.txt and consolidate dependencies there.
project-structure:pyproject-missing
Section titled “project-structure:pyproject-missing”pyproject.toml not found
Fix. pyproject.toml is required as the single build configuration file.
project-structure:orm-init-empty
Section titled “project-structure:orm-init-empty”ORM init.py has no imports
Fix. repositories/orm/init.py must import all ORM models for Alembic autogeneration.
project-structure:missing-test-coverage
Section titled “project-structure:missing-test-coverage”Service or controller has no corresponding test file
Fix. Add a test file under tests/unit/ covering its core invariants.
Session
Section titled “Session”session · 1 rule
session:session-execute-location
Section titled “session:session-execute-location”session.execute() called outside repositories/
Fix. Extract the query into a repository method, add it to IYourRepository, and call it from there.
Structural
Section titled “Structural”structural · 25 rules
structural:import-not-at-top
Section titled “structural:import-not-at-top”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.
structural:import-not-at-top-nested
Section titled “structural:import-not-at-top-nested”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.
structural:getattr-setattr-banned
Section titled “structural:getattr-setattr-banned”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.
structural:isinstance-banned
Section titled “structural:isinstance-banned”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.
structural:class-attribute-banned
Section titled “structural:class-attribute-banned”class attribute access in application code
Fix. Metaprogramming via class is banned. Use match/case for type-based branching instead.
structural:percent-interpolation-banned
Section titled “structural:percent-interpolation-banned”Percent (%) string interpolation used
Fix. Use f-strings for application strings. For logger calls use structured keyword arguments: logger.info(‘message’, key=value).
structural:logger-fstring
Section titled “structural:logger-fstring”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).
structural:print-banned
Section titled “structural:print-banned”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.
structural:non-dataclass-in-layer
Section titled “structural:non-dataclass-in-layer”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.
structural:dataclass-no-slots
Section titled “structural:dataclass-no-slots”@dataclass without slots=True
Fix. Dataclasses must use slots=True for performance and memory efficiency.
structural:dataclass-no-frozen
Section titled “structural:dataclass-no-frozen”@dataclass in models/ without frozen=True
Fix. Value objects and output models in models/ must use frozen=True to ensure immutability.
structural:misplaced-dataclass
Section titled “structural:misplaced-dataclass”@dataclass with zero methods in services/, controllers/, or repositories/
Fix. This is a model, not a service/controller/repository. Move it to models/.
structural:logger-dataclass-field
Section titled “structural:logger-dataclass-field”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).
structural:app-error-direct-raise
Section titled “structural:app-error-direct-raise”AppError raised directly
Fix. Raise a named subclass instead: raise NotFoundError(…). Define new errors in errors.py if needed.
structural:app-error-http-status
Section titled “structural:app-error-http-status”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.
structural:try-except-routes
Section titled “structural:try-except-routes”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.
structural:factory-no-staticmethod
Section titled “structural:factory-no-staticmethod”@staticmethod on AppFactory or CheckerFactory
Fix. The factory is instantiated per-request and carries session and user context. Make it a regular instance method.
structural:factory-zero-logic
Section titled “structural:factory-zero-logic”Conditional logic in AppFactory or CheckerFactory
Fix. The factory wires dependencies and makes no decisions. Move the conditional logic into a service method.
structural:bare-column-banned
Section titled “structural:bare-column-banned”Bare Column() used instead of Mapped[T] in ORM models
Fix. Use Mapped[T] for ORM column types instead of bare Column().
structural:missing-protocol
Section titled “structural:missing-protocol”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.
structural:http-exception-location
Section titled “structural:http-exception-location”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.
structural:match-case-location
Section titled “structural:match-case-location”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.
structural:toplevel-function-in-service
Section titled “structural:toplevel-function-in-service”Top-level standalone function in services/
Fix. Services must be @dataclass classes, not standalone functions. Move this function into a service class.
structural:status-code-location
Section titled “structural:status-code-location”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.
structural:concrete-service-import
Section titled “structural:concrete-service-import”Concrete service class imported outside factory.py or controllers/
Fix. Factories and controllers assemble concrete implementations. Import the Protocol interface everywhere else.
Test Structure
Section titled “Test Structure”test-structure · 5 rules
test-structure:test-file-location
Section titled “test-structure:test-file-location”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/.
test-structure:one-assert-per-test
Section titled “test-structure:one-assert-per-test”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-structure:test-naming
Section titled “test-structure:test-naming”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.
test-structure:skip-without-reason
Section titled “test-structure:skip-without-reason”@pytest.mark.skip without a reason
Fix. Add reason= explaining why this test is skipped and when it should be re-enabled.
test-structure:banned-import-in-tests
Section titled “test-structure:banned-import-in-tests”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.