Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:

- name: Set up Python
if: ${{ matrix.python-version == '2.7' }}
uses: LizardByte/actions/actions/setup_python@eddc8fc8b27048e25040e37e3585bd3ef9a968ed # master
uses: LizardByte/actions/actions/setup_python@a46850981292c4bbc0c41715f286ad95adaaaf4a # v2026.625.20301
with:
python-version: ${{ matrix.python-version }}

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ docs/source/transforms/*.min.py
.coverage
.mypy_cache/
.tox/
docs/source/minification_options/*.min.py
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ and will output source code compatible with the version of the interpreter it is
This means that if you minify code written for Python 3.11 using python-minifier running with Python 3.12,
the minified code may only run with Python 3.12.

## [Unreleased]

### Added
- New transform to remove `if` statement branches that have no effect because the condition is always `True` or `False`. This is enabled by default and can be disabled with the `--no-remove-dead-branches` option.
- Constant folding can now fold boolean (`and`/`or`) and comparison (`==`, `is`, `<`, ...) expressions with literal operands.

### Fixed
- The remove debug transform no longer removes branches if they affect the program, even though they aren't executed, e.g. they contain `yield`, a `global`/`nonlocal` declaration, or the only binding of a local name. It also now properly inlines the else branch.

## [3.2.0] - 2025-12-31

### Added
Expand Down
36 changes: 24 additions & 12 deletions corpus_test/generate_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,8 @@ def report_larger_than_base(results_dir: str, python_versions: list[str], minifi
yield '''
## Top 10 Larger than base

| Corpus Entry | Original Size | Minified Size |
|--------------|--------------:|--------------:|'''
| Corpus Entry | Python Version | Original Size | Minified Size |
|--------------|----------------|--------------:|--------------:|'''

there_are_some_larger_than_base = False

Expand All @@ -293,17 +293,17 @@ def report_larger_than_base(results_dir: str, python_versions: list[str], minifi
except FileNotFoundError:
continue

larger_than_original = sorted(summary.compare_size_increase(base_summary), key=lambda result: result.original_size)[:10]
larger_than_base = sorted(summary.compare_size_increase(base_summary), key=lambda result: result.original_size)[:10]

for entry in larger_than_original:
for entry in larger_than_base:
there_are_some_larger_than_base = True
yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - base_summary.entries[entry.corpus_entry].minified_size:+}) |'
yield f'| {entry.corpus_entry} | {python_version} | {entry.original_size} | {entry.minified_size} ({entry.minified_size - base_summary.entries[entry.corpus_entry].minified_size:+}) |'

if not there_are_some_larger_than_base:
yield '| None | | |'
yield '| None | | | |'


def report_slowest(results_dir: str, python_versions: list[str], minifier_sha: str) -> str:
def report_slowest(results_dir: str, python_versions: list[str], minifier_sha: str, base_sha: str) -> str:
yield '''
## Top 10 Slowest

Expand All @@ -316,8 +316,18 @@ def report_slowest(results_dir: str, python_versions: list[str], minifier_sha: s
except FileNotFoundError:
continue

try:
base_summary = result_summary(results_dir, python_version, base_sha)
except FileNotFoundError:
base_summary = None

for entry in sorted(summary.entries.values(), key=lambda entry: entry.time, reverse=True)[:10]:
yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} | {entry.time:.3f} |'
time_detail = f'{entry.time:.3f}'

if base_summary is not None and entry.corpus_entry in base_summary.entries:
time_detail += f' ({entry.time - base_summary.entries[entry.corpus_entry].time:+.3f})'

yield f'| {entry.corpus_entry} | {entry.original_size} | {entry.minified_size} | {time_detail} |'

def format_size_change_detail(summary, base_summary) -> str:
mean_percent_of_original_change = summary.mean_percent_of_original - base_summary.mean_percent_of_original
Expand Down Expand Up @@ -400,11 +410,13 @@ def report(results_dir: str, minifier_ref: str, minifier_sha: str, base_ref: str
f'| {format_difference(summary.exception(), base_summary.exception())} |'
)

all_versions = ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']

yield from report_larger_than_original(results_dir, ['3.14'], minifier_sha)
yield from report_larger_than_base(results_dir, ['3.13'], minifier_sha, base_sha)
yield from report_slowest(results_dir, ['3.14'], minifier_sha)
yield from report_unstable(results_dir, ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'], minifier_sha)
yield from report_exceptions(results_dir, ['2.7', '3.3', '3.4', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'], minifier_sha)
yield from report_larger_than_base(results_dir, all_versions, minifier_sha, base_sha)
yield from report_slowest(results_dir, ['3.14'], minifier_sha, base_sha)
yield from report_unstable(results_dir, all_versions, minifier_sha)
yield from report_exceptions(results_dir, all_versions, minifier_sha)


def main():
Expand Down
13 changes: 10 additions & 3 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,18 @@ def create_example(option):
'prefer_single_line': False
}

with open(f'minification_options/{option}.py') as source:
source_code = source.read()

# Output with the option enabled
options[option] = True
with open(f'minification_options/{option}.min.py', 'w') as minified:
minified.write(minify(source_code, filename=f'{option}.py', **options))

with open(f'minification_options/{option}.py') as source:
with open(f'minification_options/{option}.min.py', 'w') as minified:
minified.write(minify(source.read(), filename=f'{option}.py', **options))
# Output with the option disabled, for pages that show both
options[option] = False
with open(f'minification_options/{option}_false.min.py', 'w') as minified:
minified.write(minify(source_code, filename=f'{option}.py', **options))

for file in os.listdir('minification_options'):
if file.endswith('.py') and not file.endswith('.min.py'):
Expand Down
3 changes: 0 additions & 3 deletions docs/source/minification_options/combine_imports.min.py

This file was deleted.

2 changes: 0 additions & 2 deletions docs/source/minification_options/constant_folding.min.py

This file was deleted.

This file was deleted.

12 changes: 0 additions & 12 deletions docs/source/minification_options/hoist_literals.min.py

This file was deleted.

1 change: 1 addition & 0 deletions docs/source/minification_options/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ They can be enabled or disabled through the minify function, or passing options
remove_explicit_return_none
remove_builtin_exception_brackets
constant_folding
remove_dead_branches

.. toctree::
:caption: Disabled by default
Expand Down
1 change: 0 additions & 1 deletion docs/source/minification_options/prefer_single_line.min.py

This file was deleted.

This file was deleted.

3 changes: 0 additions & 3 deletions docs/source/minification_options/preserve_shebang.min.py

This file was deleted.

3 changes: 0 additions & 3 deletions docs/source/minification_options/remove_annotations.min.py

This file was deleted.

2 changes: 0 additions & 2 deletions docs/source/minification_options/remove_asserts.min.py

This file was deleted.

This file was deleted.

11 changes: 11 additions & 0 deletions docs/source/minification_options/remove_dead_branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
if False:
print('never runs')

if True:
print('always runs')
else:
print('never runs')

DEBUG = False
if DEBUG:
print('DEBUG is not a literal, so this is left alone')
36 changes: 36 additions & 0 deletions docs/source/minification_options/remove_dead_branches.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Remove Dead Branches
====================

This transform removes ``if`` statement branches that can never run because the condition is a constant.

An ``if`` statement with a constant ``False`` condition is removed, keeping its ``else`` branch. An ``if`` statement
with a constant ``True`` condition is replaced by its body, dropping any ``else`` branch. Only the literals ``True``
and ``False`` are treated as constants; the condition is not otherwise evaluated, so a name such as ``DEBUG`` is left
alone even if it was assigned a constant value.

A branch is only removed when doing so does not change the meaning of the program. A branch is kept if removing it
would change the enclosing scope, for example if it contains a ``yield``, a ``global`` or ``nonlocal`` declaration, or
the only assignment to a local variable. At module and class scope, where name resolution is dynamic, unreachable
assignments and imports are removed.

If a branch is removed and a statement is still required, it is replaced by a zero expression statement.

This transform also removes the ``if __debug__`` branches marked by the :doc:`remove_debug` transform, so enabling
that transform requires this one to remain enabled.

This transform is safe and enabled by default. Disable it by passing the ``remove_dead_branches=False`` argument to the
:func:`python_minifier.minify` function, or passing ``--no-remove-dead-branches`` to the pyminify command.

Example
-------

Input
~~~~~

.. literalinclude:: remove_dead_branches.py

Output
~~~~~~

.. literalinclude:: remove_dead_branches.min.py
:language: python
6 changes: 0 additions & 6 deletions docs/source/minification_options/remove_debug.min.py

This file was deleted.

10 changes: 7 additions & 3 deletions docs/source/minification_options/remove_debug.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ Remove Debug
This transform removes ``if`` statements that test ``__debug__`` is ``True``.

The builtin ``__debug__`` constant is True if Python is not started with the ``-O`` option.
This transform is only safe to use if the minified output will by run with the ``-O`` option, or
This transform is only safe to use if the minified output will be run with the ``-O`` option, or
you are certain that any ``if`` statement that tests ``__debug__`` can be removed.

The condition is not evaluated. The statement is only removed if the condition exactly matches one of the forms in the example below.
The condition is not evaluated. A statement is only affected if the condition exactly matches one of the truthy forms
in the example below.

If a statement is required, the ``if`` statement will be replaced by a zero expression statement.
The removal is performed by the :doc:`remove_dead_branches` transform, which this transform marks the ``if`` statements
for. That transform must remain enabled (it is by default) for this option to have any effect, and it will keep a branch
that cannot be removed without changing the meaning of the program. If a branch is removed and a statement is still
required, it is replaced by a zero expression statement.

The transform is disabled by default. Enable it by passing the ``remove_debug=True`` argument to the :func:`python_minifier.minify` function,
or passing ``--remove-debug`` to the pyminify command.
Expand Down

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion docs/source/minification_options/remove_object_base.min.py

This file was deleted.

1 change: 0 additions & 1 deletion docs/source/minification_options/remove_pass.min.py

This file was deleted.

5 changes: 0 additions & 5 deletions docs/source/minification_options/rename_globals.min.py

This file was deleted.

6 changes: 0 additions & 6 deletions docs/source/minification_options/rename_locals.min.py

This file was deleted.

12 changes: 9 additions & 3 deletions src/python_minifier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from python_minifier.transforms.remove_annotations import RemoveAnnotations
from python_minifier.transforms.remove_annotations_options import RemoveAnnotationsOptions
from python_minifier.transforms.remove_asserts import RemoveAsserts
from python_minifier.transforms.remove_dead_branches import RemoveDeadBranches
from python_minifier.transforms.remove_debug import RemoveDebug
from python_minifier.transforms.remove_exception_brackets import remove_no_arg_exception_call
from python_minifier.transforms.remove_explicit_return_none import RemoveExplicitReturnNone
Expand Down Expand Up @@ -74,6 +75,7 @@ def minify(
remove_builtin_exception_brackets=True,
constant_folding=True,
prefer_single_line=False,
remove_dead_branches=True
):
"""
Minify a python module
Expand Down Expand Up @@ -109,6 +111,7 @@ def minify(
:param bool remove_builtin_exception_brackets: If brackets should be removed when raising exceptions with no arguments
:param bool constant_folding: If literal expressions should be evaluated
:param bool prefer_single_line: If semi-colons should be preferred over newlines where there is no difference in output size
:param bool remove_dead_branches: If if-statements with a constant False test should be removed

:rtype: str

Expand Down Expand Up @@ -155,12 +158,15 @@ def minify(
if remove_debug:
module = RemoveDebug()(module)

if remove_explicit_return_none:
module = RemoveExplicitReturnNone()(module)

if constant_folding:
module = FoldConstants()(module)

if remove_dead_branches:
module = RemoveDeadBranches()(module)

if remove_explicit_return_none:
module = RemoveExplicitReturnNone()(module)

bind_names(module)
resolve_names(module)

Expand Down
3 changes: 2 additions & 1 deletion src/python_minifier/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def minify(
remove_explicit_return_none: bool = ...,
remove_builtin_exception_brackets: bool = ...,
constant_folding: bool = ...,
prefer_single_line: bool = ...
prefer_single_line: bool = ...,
remove_dead_branches: bool = ...
) -> Text: ...


Expand Down
10 changes: 10 additions & 0 deletions src/python_minifier/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ def parse_args():
help='Disable evaluating literal expressions',
dest='constant_folding',
)
minification_options.add_argument(
'--no-remove-dead-branches',
action='store_false',
help='Disable removing branches that are never executed',
dest='remove_dead_branches',
)

annotation_options = parser.add_argument_group('remove annotations options', 'Options that affect how annotations are removed')
annotation_options.add_argument(
Expand Down Expand Up @@ -305,6 +311,10 @@ def parse_args():
sys.stderr.write('error: --remove-class-attribute-annotations would do nothing when used with --no-remove-annotations\n')
sys.exit(1)

if args.remove_debug and not args.remove_dead_branches:
sys.stderr.write('error: --remove-debug would do nothing when used with --no-remove-dead-branches\n')
sys.exit(1)

return args


Expand Down
2 changes: 1 addition & 1 deletion src/python_minifier/module_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ def visit_match_case(self, node):

if node.guard is not None:
self.printer.keyword('if')
self.visit(node.guard)
self._expression(node.guard)

self.printer.delimiter(':')
self._suite(node.body)
Expand Down
4 changes: 3 additions & 1 deletion src/python_minifier/rename/rename_literals.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections import OrderedDict

import python_minifier.ast_compat as ast
from python_minifier.ast_annotation import get_parent, set_parent

Expand Down Expand Up @@ -118,7 +120,7 @@ class HoistLiterals(NodeVisitor):
def __call__(self, module, ignore_slots=True):
self.module = module
self._ignore_slots = ignore_slots
self._hoisted = {}
self._hoisted = OrderedDict()
self.visit(module)
self.place_bindings()

Expand Down
Loading
Loading