This page describes Fensu’s default model. Every part of it is a rule you can
turn off with
ignore, narrow with
scopes, or replace with a custom rule. The
layout below is the shipped starting point, not a fixed requirement.Scopes
A scope is a set of paths that receives a particular policy. Fensu has three, each declared in configuration:
The scanned set is the union of these three. A file that is not in any scope, such
as a top-level
setup.py or noxfile.py, is simply skipped. There is no “scanned
but unclassified” state.
rootsis required and its entries must not be nested inside one another, so every scanned file belongs to exactly one root. Independent package trees in a monorepo are expressed as multiple roots.testsdefaults to["tests"].toolingdefaults to empty.
src/my_package, the file src/my_package/config/main/load.py has relative parts
("config", "main", "load.py"), from which Fensu derives its domain, subdomain,
and role.
Roles
A role is what a file or directory is for. Fensu recognizes a fixed set of role names and enforces what each may contain and where it may live.
The underscore in
_helpers/ means private to its owner, not unimportable. A
main/ entry importing its own _helpers/ implementation is the intended pattern;
another owner reaching into that package is not. The semantic role name remains
helpers in configuration, including [roles.helpers] and
max_helpers_container_modules.
These roles are Fensu’s opinion and are fixed. A role file may hold only what its
role permits: a declaration that belongs in models.py may not live elsewhere, and
models.py may not hold unrelated code.
Ownership follows the role boundary: everything before a module’s first role
segment is its owner. Imports within one owner are free (main/ calling its
own _helpers/, helpers calling each other, anything using the owner’s
classes/). Imports across owners must enter through the owner’s public
surface: its role files or role packages (models, types, constants,
exceptions, classes) or its main/ entry modules. Another owner’s _helpers/
internals are never importable. For an importable path with no role segment,
Fensu infers the containing package as its owner (or the package itself for an
__init__.py); direct non-role modules remain internal rather than becoming
accidental public surfaces.
Role names are fixed today. User-defined role taxonomies are a planned future
addition, not current behavior.
Domains: leaf or branch
Fensu’s default layout for product code is domains built from roles, not a flat top level.- The package root (for example
src/my_package/) holds only its__init__.pypublic surface and domain packages. No role files live at the root. - A leaf domain holds the actual work directly:
main/,_helpers/,classes/, and the role files (models.py,types.py,constants.py,exceptions.py). - A branch domain holds only named subdomains, each of which is itself built from the roles.
FFR306). A cohesive single-purpose domain is simply a
leaf; it splits into named subdomains only when it grows genuinely independent
parts. There is no mandatory subdomain level and no conventional core/
ceremony. Loose non-role modules directly at the domain level fault too
(FFR307).
Vocabulary lives with its owner
Fensu has no neutral “shared” or “utils” bucket. Every type lives in the role file of the domain or subdomain that produces it, and other packages import it from there. A type that many places use is not ownerless; it has one producer, and that producer publishes it. If you cannot name the owner, that is a signal to look harder, not to create a junk drawer.Shared domain prefixes
When sibling domains repeatedly start with the same first underscore-separated token, that token is already acting like an owner. FFR308 asks you to make the ownership explicit:annotation_export/, annotation_sanitization/, and annotation_validation/
belong under annotation/.
The rule applies when at least min_shared_domain_prefix_packages sibling domains
share a prefix. The default is 2; set it to 0 to disable the check. Directories
without Python files do not count.
Containers: flat or grouped
main/ and _helpers/ are containers. A container has one of two shapes:
- Flat: modules sit directly in the package, up to a cap
(
max_helpers_container_modules, default 10, for_helpers/;max_main_container_modules, default 20, formain/). - Grouped: every module is grouped into one level of concern subfolders (buckets), each bucket holding modules up to the same cap.
FFR301 for _helpers/,
FFR302 for main/): once one bucket exists, direct modules may not sit beside
it. Bucket nesting is capped by max_role_depth (default 1), so a bucket does not
grow buckets of its own. Bucket names must name a concern: a role name (main,
helpers, classes, …) as a bucket name faults under the same layout rules,
and generic names (util, common, misc, …) fault under FFR204.
Grouping is pure navigation, not new ownership: a grouped main/ or _helpers/
tree still belongs to one owner, and modules inside it keep their role’s rules.
In particular, the entry-module contract applies to every
module under main/, bucketed or not.
Entry modules
A module under amain/ package (directly, or inside a bucket) is an entry
module. Its shape rule (FFR401) constrains only the module’s top level:
- exactly one public function (the entry point);
- at most two private functions in that file;
- top level contains only imports and function definitions, nothing else.
_helpers/, classes from classes/, other packages’ published
surfaces, and anything else it has legitimate access to. The two-private-function
cap is not a cap on collaborators; it just says an entry module should not grow its
own pile of local glue. When you need more than a couple of local helpers, move them
into _helpers/ and call them from there.
load_config calls four helper functions and several imported types. That is
entirely fine: it is one public function with zero private ones, and all the real
work lives in _helpers/. The rule would only fire if the file grew a second public
function, a third private function, or a top-level statement that is not an import
or a def.
Default thresholds
Several shape rules are governed by numeric thresholds. Fensu ships these defaults, all overridable in configuration:
These are measured values from a large, real, self-hosted codebase, presented as
documented defaults rather than magic numbers. See
Configuration for how to override them globally, per
role, or per path.
Tooling layout
Tooling code (declared intooling) follows the same roles as product code but at
one less nesting level, because dev scripts rarely need full domain depth.
- Direct
scripts/*.pyfiles are thin command adapters: one publicmain(), an optional_parse_args()or_build_parser(), imports, theif __name__ == "__main__"guard, and delegation to an importedmain/entry.scripts/__init__.pyis exempt. - A tool implementation uses one ownership level then a role:
main/, _helpers/,
classes/, and rules/. A rules/ module is the home for custom rules; see
Custom rules.
Test layout
The tests scope (declared intests) has its own shape, so a test’s location and
form tell you what it exercises and how to read it.
- Mirrored. Tests live under a scope of
unit/,integration/, ore2e/, then mirror the source path they exercise. A test forsrc/my_package/config/main/lives attests/unit/src/my_package/config/main/. - Named for behavior. Test files start with
test_, and each test istest_given_<state>_when_<action>_then_<outcome>, so the precondition, action, and expectation are readable without opening the body. - Dataclass-backed cases. Tests parametrize over a local frozen dataclass rather
than tuples or dicts. Each case carries a
descriptionand at least oneexpected_field, and the test asserts against that field. Case ids come fromlambda case: case.description, so a failure names the behavior.
_test_types.py, and reusable test helpers live
in a local helpers.py. See FFT: Tests for the
rules that enforce this layout.
Related
Configuration
Declare scopes, override thresholds, and set contracts.
Rule families
The rules that enforce this model.

