a Python tool to enforce modular design
Discord - come say hi!
tach_demo.mp4
tach
allows you to define boundaries and control dependencies between your Python packages. Each package can also define its public interface.
This enforces a decoupled, modular architecture, and prevents tight coupling.
If a package tries to import from another package that is not listed as a dependency, tach will report an error.
If a package tries to import from another package and does not use its public interface, with strict: true
set, tach
will report an error.
tach
is incredibly lightweight, and has no impact on your runtime. Instead, its checks are performed as a lint check through the CLI.
pip install tach
tach
comes bundled with a command to set up and define your initial boundaries.
tach init
By running tach init
from the root of your Python project, tach
will initialize each top-level Python package. Each package will receive a package.yml
with a single tag based on the folder name.
The tool will take into consideration the usages between packages, and write a matching set of dependencies to tach.yml
in the project root.
If you'd like to incrementally or individually add new packages to your tach.yml
, you can use:
tach add [file_or_path]
This will create a boundary around the given file or directory, and update your tach.yml
with the correct set of dependencies.
To define a package, add a package.yml
to the corresponding Python package. Add at least one 'tag' to identify the package:
# core/package.yml
tags: ["core"]
# db/package.yml
tags: ["db"]
# utils/package.yml
tags: ["utils"]
Next, specify the constraints for each tag in tach.yml
in the root of your project:
# [root]/tach.yml
constraints:
- tag: core
depends_on:
- db
- utils
- tag: db
depends_on:
- utils
- tag: utils
depends_on: []
With these rules in place, packages with tag core
can import from packages with tag db
or utils
. Packages tagged with db
can only import from utils
, and packages tagged with utils
cannot import from any other packages in the project.
tach
will now flag any violation of these boundaries.
# From the root of your Python project (in this example, `project/`)
> tach check
❌ ./utils/helpers.py: Import "core.PublicAPI" is blocked by boundary "core". Tag(s) ["utils"] do not have access to ["core"].
If you want to define a public interface for the package, import and reference each object you want exposed in the package's __init__.py
and add its name to __all__
:
# db/__init__.py
from db.service import PublicAPI
__all__ = ["PublicAPI"]
Turning on strict: true
in the package's package.yml
will then enforce that all imports from this package occur through __init__.py
and are listed in __all__
# db/package.yml
tags: ["db"]
strict: true
# The only valid import from "db"
from db import PublicAPI
tach
can be installed as a pre-commit hook. See the docs for installation instructions.
tach
supports specific exceptions. You can mark an import with the tach-ignore
comment:
# tach-ignore
from db.main import PrivateAPI
This will stop tach
from flagging this import as a boundary violation.
You can also specify multiple tags for a given package:
# utils/package.yml
tags: ["core", "utils"]
This will expand the set of packages that "utils" can access to include all packages that "core" and "utils" depends_on
as defined in tach.yml
.
By default, tach
ignores hidden directories and files (paths starting with .
). To override this behavior, set exclude_hidden_paths
in tach.yml
exclude_hidden_paths: false
tach
works by analyzing the abstract syntax tree (AST) of your codebase. It has no runtime impact, and all operations are performed statically.
Boundary violations are detected at the import layer. This means that dynamic imports using importlib
or similar approaches will not be caught by tach.