py-terser
A mangler/minifier toolkit for Python.
Fork of dflook/python-minifier, Name inspired by
terser
Transforms Python project into its most compact representation.
py-terser currently supports Python 3.13 to Python 3.14.
- Single-file mode — minifies one module (or stdin) on its own, like
python-minifier. - Project mode — minifies a whole directory tree at once. Imports are linked across modules, which enables renaming global names and module names consistently across the project, and dropping modules that are unreachable from the entry points (tree-shaking).
- Hatch build hook — minifies the Python sources that go into your wheel at build time.
Installation
pip install py-terser
The command-line interface additionally requires pydantic and tqdm:
pip install py-terser pydantic tqdm
# or, as a standalone tool
uv tool install py-terser --with pydantic --with tqdm
To work on py-terser itself:
git clone https://github.com/MadeByAlpha/py-terser.git
cd py-terser
uv sync --group cli --group dev
uv run terser --help
Command-line usage
terser [options] path [path ...]
The mode is chosen from the given paths:
| Paths | Mode | Output |
|---|---|---|
- |
single-file | Reads stdin, writes stdout (or --output) |
| a single file | single-file | stdout, --output FILE or --in-place |
| a directory, or multiple paths | project | --output DIR or --in-place (one is required) |
In project mode, directories are searched recursively for *.py/*.pyw files, and the output directory mirrors the
input layout. A directory that is a package itself (it has an __init__.py) keeps its name as the top-level package:
terser src/mypkg --output build/mypkg names its modules mypkg.* and writes build/mypkg/__init__.py.
Examples
# Minify stdin to stdout
terser -
# Minify a file to stdout
terser example.py
# Minify a file into another file
terser example.py --output example.min.py
# Minify a file in place
terser example.py --in-place
# Minify a whole project into a separate directory
terser src/ --output build/
# Minify multiple paths in place
terser file1.py file2.py src/ --in-place
# Also rename global names and module names across the project
terser src/ --output build/ --rename-globals --rename-modules
# Keep only modules reachable from `app.main` (tree-shaking)
terser src/ --output build/ --entry app.main
python -m terser works the same as terser.
Options
Boolean options can be given alone (--rename-globals) or with a value (--rename-locals False; yes/no and
1/0 also work). Options that accept several values
(--preserve-locals, --preserve-globals, --preserve-modules, --entry, --contracts) can be given
multiple values, and can be repeated.
General
| Option | Default | Description |
|---|---|---|
--output PATH |
stdout | File (single-file mode) or directory (project mode) to write output to |
--in-place |
False |
Overwrite the input files. Mutually exclusive with --output |
--preserve-shebang |
True |
Keep the shebang (#!...) line |
--prefer-single-line |
False |
Join statements with ; instead of newlines, even when it saves no bytes |
--workers N |
auto | Number of worker threads in project mode |
--entry MODULE |
— | Entry point modules (dotted module path or file path), project mode only. See Tree-shaking |
Transforms
| Option | Default | Description |
|---|---|---|
--passes N |
5 |
Maximum number of transform passes. Stops early once nothing changes |
--optimize {-1,0,1,2} |
-1 |
Passed to ast.parse(). 2 also enables --remove-debug and --remove-asserts |
--apply-contracts |
True |
Rewrite calls according to --contracts. See Contracts |
--contracts RULE |
see below | Contract rules to apply |
--remove-literal-statements |
False |
Remove statements that are a single literal (e.g. docstrings) |
--combine-imports |
True |
Combine adjacent import statements |
--remove-annotations |
True |
Remove type annotations, as selected by the four options below |
--remove-variable-annotations |
True |
Remove variable annotations |
--remove-return-annotations |
True |
Remove return annotations |
--remove-argument-annotations |
True |
Remove argument annotations |
--remove-attribute-annotations |
False |
Remove class attribute annotations |
--remove-explicit-base |
True |
Remove explicit base classes (e.g. class A(object)) |
--remove-explicit-return-none |
True |
Replace return None with return |
--fold-constants |
True |
Evaluate constant expressions and shrink literals |
--remove-debug |
True |
Remove if __debug__: blocks |
--remove-asserts |
True |
Remove assert statements |
--convert-pass |
True |
Remove pass, or replace it with the shortest literal statement (0) |
--remove-empty-exc-brackets |
True |
raise ValueError() → raise ValueError for built-in exceptions (not in modules using exec(), an external import *, …) |
--convert-posargs |
True |
Convert positional-only arguments to normal arguments (not for functions taking **kwargs) |
Mangling
| Option | Default | Description |
|---|---|---|
--hoist-literals |
True |
Replace frequently used literals with short-named variables |
--rename-locals |
True |
Rename local (including nonlocal) names |
--preserve-locals NAMES |
— | Local names that are not renamed. See Preserving names |
--rename-globals |
False |
Rename module-level names. In project mode, importers in other modules follow |
--preserve-globals NAMES |
— | Global names that are not renamed |
--rename-modules |
False |
Rename module/package files and directories. Project mode only, requires --output |
--preserve-modules PATTERN |
— | Glob patterns over dotted module paths; matching modules keep their name |
Preserving names
--preserve-locals and --preserve-globals take comma-separated names. Prefix them with a glob pattern and : to
limit them to matching modules (matched against the dotted module path, or the filename in single-file mode):
# Keep `config` and `logger` in every module
terser src/ --output build/ --rename-globals --preserve-globals config,logger
# Keep `handler` only in modules under `app.api`
terser src/ --output build/ --rename-globals --preserve-globals 'app.api.*:handler'
Tree-shaking
When --entry is given, modules that are not reachable (through imports) from any entry module are dropped from the
output. Entry modules are never renamed by --rename-modules.
terser src/ --output build/ --entry app.main app.cli --rename-modules
Hatch build hook
py-terser registers a Hatch build hook named terser, which minifies the .py/.pyw
files included in a wheel. Source distributions are left untouched.
[build-system]
requires = ["hatchling", "py-terser"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel.hooks.terser]
hoist_literals = true
rename_locals = true
rename_globals = false
# glob pattern -> names, same as `--preserve-locals`/`--preserve-globals`
preserve_globals = { "*" = ["VERSION"], "mypkg.api.*" = ["handler"] }
# Transform options (same names as the `Transforms` table above, in snake_case)
[tool.hatch.build.targets.wheel.hooks.terser.config]
passes = 5
remove_literal_statements = true
remove_annotations = true
Supported keys:
- Top level:
hoist_literals,rename_locals,preserve_locals,rename_globals,preserve_globals configtable: everyTransformConfigfield (see Python API).remove_annotationsalso takes a table of the fourremove_*_annotationsoptions.
Python API
import anyio
from terser import TransformConfig, minify, minify_project
config = TransformConfig(remove_literal_statements=True)
# Single module
with open("example.py") as f:
print(minify(f.read(), config, "example.py"))
# Whole project (async)
anyio.run(
lambda: minify_project(
config,
{"src"},
output=anyio.Path("build"),
rename_globals=True,
preserve_globals={"*": ["VERSION"]},
entry={"app.main"},
)
)
TransformConfig has the same fields as the transform options above (remove_annotations also accepts a
RemoveAnnotationOptions instead of a bool). minify_project accepts the mangling options as keyword arguments,
plus workers, rename_modules, preserve_modules and entry.
Contracts
Contracts rewrite calls to known functions into a simpler expression. Each rule has the form
module.function(args) -> replacement:
- Each argument is a name, or
_for an argument that is ignored. - The replacement is an expression over those names, or
Noneto replace the call withNone.
The default rules are:
typing.cast(_, value) -> value
typing.assert_never(_) -> None
typing.assert_type(x, _) -> x
Calls are matched by resolving the called name to its import (e.g. from typing import cast). Passing
--contracts replaces the default rules.
Conditional directives
Before parsing, comment directives select which lines are kept, similar to the C preprocessor:
# if DEBUG
log_everything()
# elif VERBOSE
log_some()
# else
log_nothing()
# endif
check_invariants() # if DEBUG
Values are given with the defines argument of the Python API (e.g. defines={"DEBUG": False}); names that are
not defined are treated as True. With strict=True, only the exact # if NAME / #if NAME spellings are
recognized, and an unbalanced directive (e.g. a missing # endif) raises SyntaxError. Removed lines are replaced
with empty lines, so line numbers in error messages match the original source.
License
MIT. See ACKNOWLEDGMENTS.
Release files for terser-hints 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| terser_hints-0.2.0.tar.gz | 6.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| terser_hints-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 12.2 kB
Release files / terser_hints-0.2.0.tar.gz
| Download URL | terser_hints-0.2.0.tar.gz |
|---|---|
| Size | 6.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8351e36e7da28289e05b9224284b552f8899b0b36161045ca8b44dae1f872b53
|
|
BLAKE2b-256 checksum How to use checksums |
81b7edc32bef811c92f5b4b3e2e50dc37054d18039dfb054cc25106a26ed45a2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"45","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / terser_hints-0.2.0-py3-none-any.whl
| Download URL | terser_hints-0.2.0-py3-none-any.whl |
|---|---|
| Size | 6.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
956bf180d4430c464c41a2594f091f39d6cbe4e1cb2adf8d23627c00bdc0940c
|
|
BLAKE2b-256 checksum How to use checksums |
d07231fb5a6968afdaeae076023a9e5d27a7b35baf35b3b23f1a6ef14120584d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"45","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|