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
53 changes: 45 additions & 8 deletions .config/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,39 @@ builtins = ["unicode"]

line-length = 200

exclude = ["docs"]

[lint]
# Enable most rules
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"S", # flake8-bandit (security)
"ERA", # eradicate (commented-out code)
"YTT", # flake8-2020 (sys.version checks)
"FBT", # flake8-boolean-trap (boolean positional arguments)
"A", # flake8-builtins (shadowing builtins)
"COM", # flake8-commas (trailing commas)
"C4", # flake8-comprehensions (comprehension improvements)
"ISC", # flake8-implicit-str-concat (string concatenation)
"PYI", # flake8-pyi (stub file checks)
"PT", # flake8-pytest-style (pytest best practices)
]

ignore = [
"I001", # Import block unsorted
"W292", # No newline at end of file
"W293", # Blank line contains whitespace
"S101", # Use of assert - intentional for validation and type checking
"S307", # Use of eval() - intentional and safe in this codebase (constant folding, validation)
"FBT002", # Boolean default positional arguments - API design choice throughout codebase
"COM812", # Missing trailing comma - we prohibit trailing commas instead
]

# Per-file ignores for specific compatibility needs
[lint.per-file-ignores]
# AST compatibility module needs star imports for Python version compatibility
"src/python_minifier/ast_compat.py" = ["F403", "F405"]
# AST compatibility module needs star imports and shadows Ellipsis builtin for Python version compatibility
"src/python_minifier/ast_compat.py" = ["F403", "F405", "A001"]

# __init__.py files intentionally re-export for public API
"*/__init__.py" = ["F401"]
Expand All @@ -37,6 +51,29 @@ ignore = [
# Compatibility imports in utility modules
"src/python_minifier/rename/util.py" = ["F401"]

# Broad exception handling needed for candidate generation (try different quote styles)
"src/python_minifier/f_string.py" = ["S112"]
"src/python_minifier/t_string.py" = ["S112"]

# random.choice() used for variable name generation, not cryptography
"src/python_minifier/rename/name_generator.py" = ["S311"]

# Test files need exec() to validate minified code behavior and subprocess calls for integration tests
"test/**/*.py" = ["S102", "S603"]

# Testing AST constant nodes requires constructing them with boolean literal values
"test/test_is_constant_node.py" = ["FBT003"]

# Hypothesis test generators intentionally use names matching AST concepts (Ellipsis, iter, slice) and use assume(False)
# Comments like # "text{expr}" document string patterns, not commented-out code
"hypo_test/**/*.py" = ["A001", "FBT003", "ERA001"]

# Typing tests intentionally use wrong types, including parameter names that look like passwords
"typing_test/**/*.py" = ["S106"]

[lint.flake8-boolean-trap]
extend-allowed-calls = ["type", "ast.NameConstant", "ast.Constant", "NameConstant", "Constant", "ast_compat.NameConstant", "assume"]

[lint.isort]
force-single-line = false
known-first-party = ["python_minifier"]
1 change: 0 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ jobs:
uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 # v3.5.1
with:
args: --config=.config/ruff.toml check
src: src

lint_dockerfiles:
runs-on: ubuntu-24.04
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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.

## [3.1.0] - 2025-10-11
## [3.1.0] - 2025-10-10

### Added
- Python 3.14 support, including:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ and outputs source code compatible with the version of the interpreter it is run
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.

python-minifier runs with and can minify code written for Python 2.7 and Python 3.3 to 3.13.
python-minifier runs with and can minify code written for Python 2.7 and Python 3.3 to 3.14.

## Usage

Expand Down
40 changes: 19 additions & 21 deletions corpus_test/generate_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from result import Result, ResultReader

ENHANCED_REPORT = os.environ.get('ENHANCED_REPORT', True)

def is_recursion_error(python_version: str, result: Result) -> bool:
"""
Expand Down Expand Up @@ -378,35 +377,34 @@ def report(results_dir: str, minifier_ref: str, minifier_sha: str, base_ref: str

if base_summary is None:
yield (
f'| {python_version} ' +
f'| {summary.valid_count} ' +
f'| {summary.mean_time:.3f} ' +
f'| {summary.mean_percent_of_original:.3f}% ' +
f'| {len(list(summary.larger_than_original()))} ' +
f'| {len(list(summary.recursion_error()))} ' +
f'| {len(list(summary.unstable_minification()))} ' +
f'| {python_version} '
f'| {summary.valid_count} '
f'| {summary.mean_time:.3f} '
f'| {summary.mean_percent_of_original:.3f}% '
f'| {len(list(summary.larger_than_original()))} '
f'| {len(list(summary.recursion_error()))} '
f'| {len(list(summary.unstable_minification()))} '
f'| {len(list(summary.exception()))} |'
)
else:
mean_time_change = summary.mean_time - base_summary.mean_time

yield (
f'| {python_version} ' +
f'| {summary.valid_count} ' +
f'| {summary.mean_time:.3f} ({mean_time_change:+.3f}) ' +
f'| {format_size_change_detail(summary, base_summary)} ' +
f'| {format_difference(summary.larger_than_original(), base_summary.larger_than_original())} ' +
f'| {format_difference(summary.recursion_error(), base_summary.recursion_error())} ' +
f'| {format_difference(summary.unstable_minification(), base_summary.unstable_minification())} ' +
f'| {python_version} '
f'| {summary.valid_count} '
f'| {summary.mean_time:.3f} ({mean_time_change:+.3f}) '
f'| {format_size_change_detail(summary, base_summary)} '
f'| {format_difference(summary.larger_than_original(), base_summary.larger_than_original())} '
f'| {format_difference(summary.recursion_error(), base_summary.recursion_error())} '
f'| {format_difference(summary.unstable_minification(), base_summary.unstable_minification())} '
f'| {format_difference(summary.exception(), base_summary.exception())} |'
)

if ENHANCED_REPORT:
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_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)


def main():
Expand Down
8 changes: 4 additions & 4 deletions hypo_test/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ def Bytes(draw) -> ast.Constant:

@composite
def List(draw, expression) -> ast.List:
l = draw(lists(expression, min_size=0, max_size=3))
return ast.List(elts=l, ctx=ast.Load())
elements = draw(lists(expression, min_size=0, max_size=3))
return ast.List(elts=elements, ctx=ast.Load())


@composite
Expand Down Expand Up @@ -168,8 +168,8 @@ def Name(draw, ctx=ast.Load) -> ast.Name:
@composite
def UnaryOp(draw, expression) -> ast.UnaryOp:
op = draw(sampled_from([ast.UAdd(), ast.USub(), ast.Not(), ast.Invert()]))
l = draw(expression)
return ast.UnaryOp(op, l)
operand = draw(expression)
return ast.UnaryOp(op, operand)


@composite
Expand Down
1 change: 0 additions & 1 deletion hypo_test/module.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import python_minifier.ast_compat as ast

from hypothesis import assume
from hypothesis.strategies import SearchStrategy, booleans, composite, integers, lists, none, one_of, recursive, sampled_from

from .expressions import Name, arguments, expression, name
Expand Down
17 changes: 8 additions & 9 deletions hypo_test/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import keyword
import string

from hypothesis import assume
from hypothesis.strategies import booleans, composite, integers, lists, none, one_of, recursive, sampled_from, text


Expand Down Expand Up @@ -35,22 +34,22 @@ def MatchStar(draw) -> ast.MatchStar:

@composite
def MatchSequence(draw, pattern) -> ast.MatchSequence:
l = draw(lists(pattern, min_size=1, max_size=3))
patterns = draw(lists(pattern, min_size=1, max_size=3))

has_star = draw(booleans())

if has_star:
star_pos = draw(integers(min_value=0, max_value=len(l)))
l.insert(star_pos, draw(MatchStar()))
star_pos = draw(integers(min_value=0, max_value=len(patterns)))
patterns.insert(star_pos, draw(MatchStar()))

return ast.MatchSequence(patterns=l)
return ast.MatchSequence(patterns=patterns)


@composite
def MatchMapping(draw, pattern) -> ast.MatchMapping:
l = draw(lists(pattern, min_size=1, max_size=3))
patterns = draw(lists(pattern, min_size=1, max_size=3))

match_mapping = ast.MatchMapping(keys=[ast.Constant(value=0) for i in range(len(l))], patterns=l)
match_mapping = ast.MatchMapping(keys=[ast.Constant(value=0) for i in range(len(patterns))], patterns=patterns)

has_star = draw(booleans())
if has_star:
Expand Down Expand Up @@ -88,8 +87,8 @@ def MatchAs(draw, pattern) -> ast.MatchAs:

@composite
def MatchOr(draw, pattern) -> ast.MatchOr:
l = draw(lists(pattern, min_size=2, max_size=3))
return ast.MatchOr(patterns=l)
patterns = draw(lists(pattern, min_size=2, max_size=3))
return ast.MatchOr(patterns=patterns)


leaves = one_of(
Expand Down
1 change: 0 additions & 1 deletion hypo_test/strings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import ast

from hypothesis import assume
from hypothesis.strategies import (
SearchStrategy,
booleans,
Expand Down
2 changes: 1 addition & 1 deletion src/python_minifier/ast_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def counter():

if field == 'kind' and isinstance(l_ast, ast.Constant):
continue

if field == 'str' and hasattr(ast, 'Interpolation') and isinstance(l_ast, ast.Interpolation):
continue

Expand Down
2 changes: 1 addition & 1 deletion src/python_minifier/ast_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@ def __new__(cls, *args, **kwargs):
'withitem',
]:
if _node_type not in globals():
globals()[_node_type] = type(_node_type, (AST,), {})
globals()[_node_type] = type(_node_type, (AST,), {})
4 changes: 2 additions & 2 deletions src/python_minifier/expression_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ def visit_BoolOp(self, node):

if value_precedence != 0 and (
(op_precedence > value_precedence)
or op_precedence == value_precedence
and self._is_left_associative(node.op)
or (op_precedence == value_precedence
and self._is_left_associative(node.op))
):
self.printer.delimiter('(')
self._expression(v)
Expand Down
8 changes: 4 additions & 4 deletions src/python_minifier/module_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ def visit_match_case(self, node):
self.printer.keyword('case')

if isinstance(node.pattern, ast.MatchSequence):
self.visit_MatchSequence(node.pattern, open=True)
self.visit_MatchSequence(node.pattern, omit_brackets=True)
else:
self.pattern(node.pattern)

Expand Down Expand Up @@ -626,18 +626,18 @@ def visit_MatchStar(self, node):
else:
self.printer.identifier(node.name)

def visit_MatchSequence(self, node, open=False):
def visit_MatchSequence(self, node, omit_brackets=False):
assert isinstance(node, ast.MatchSequence)

if len(node.patterns) < 2 or not open:
if len(node.patterns) < 2 or not omit_brackets:
self.printer.delimiter('[')

delimiter = Delimiter(self.printer)
for pattern in node.patterns:
delimiter.new_item()
self.pattern(pattern)

if len(node.patterns) < 2 or not open:
if len(node.patterns) < 2 or not omit_brackets:
self.printer.delimiter(']')

def visit_MatchMapping(self, node):
Expand Down
2 changes: 1 addition & 1 deletion src/python_minifier/rename/renamer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def sorted_bindings(module):
"""

def comp(tup):
namespace, binding = tup
_namespace, binding = tup
return binding.new_mention_count()

return sorted(all_bindings(module), key=comp, reverse=True)
Expand Down
2 changes: 1 addition & 1 deletion src/python_minifier/token_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def __init__(self, prefer_single_line=False, allow_invalid_num_warnings=False):
def __str__(self):
"""Return the output code."""
return self._code

def __unicode__(self):
"""Return the output code as unicode (for Python 2.7 compatibility)."""
return self._code
Expand Down
4 changes: 2 additions & 2 deletions test/ast_annotation/test_add_parent.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ def b(self):
def test_no_parent_for_root_node():
tree = ast.parse('a = 1')
add_parent(tree)
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="Node has no parent"):
get_parent(tree)


def test_no_parent_for_unannotated_node():
tree = ast.parse('a = 1')
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="Node has no parent"):
get_parent(tree.body[0])


Expand Down
2 changes: 1 addition & 1 deletion test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

# Set default environment variable to preserve existing test behavior
# Tests can explicitly unset this if they need to test size-based behavior
os.environ.setdefault('PYMINIFY_FORCE_BEST_EFFORT', '1')
os.environ.setdefault('PYMINIFY_FORCE_BEST_EFFORT', '1')
35 changes: 17 additions & 18 deletions test/subprocess_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ def run_subprocess(cmd, timeout=None, input_data=None, env=None):
if hasattr(subprocess, 'run'):
# Python 3.5+ - encode string input to bytes for subprocess
input_bytes = input_data.encode('utf-8') if isinstance(input_data, str) else input_data
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
input=input_bytes, timeout=timeout, env=env)
else:
# Python 2.7, 3.3, 3.4 - no subprocess.run, no timeout support
popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stdin=subprocess.PIPE if input_data else None, env=env)
# For Python 3.3/3.4, communicate() doesn't support timeout
# Also, Python 3.x needs bytes for stdin, Python 2.x needs str
if input_data and sys.version_info[0] >= 3 and isinstance(input_data, str):
input_data = input_data.encode('utf-8')
stdout, stderr = popen.communicate(input_data)
# Create a simple result object similar to subprocess.CompletedProcess
class Result:
def __init__(self, returncode, stdout, stderr):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
return Result(popen.returncode, stdout, stderr)
# Python 2.7, 3.3, 3.4 - no subprocess.run, no timeout support
popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stdin=subprocess.PIPE if input_data else None, env=env)
# For Python 3.3/3.4, communicate() doesn't support timeout
# Also, Python 3.x needs bytes for stdin, Python 2.x needs str
if input_data and sys.version_info[0] >= 3 and isinstance(input_data, str):
input_data = input_data.encode('utf-8')
stdout, stderr = popen.communicate(input_data)
# Create a simple result object similar to subprocess.CompletedProcess
class Result:
def __init__(self, returncode, stdout, stderr):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
return Result(popen.returncode, stdout, stderr)


def safe_decode(data, encoding='utf-8', errors='replace'):
Expand All @@ -35,4 +34,4 @@ def safe_decode(data, encoding='utf-8', errors='replace'):
return data.decode(encoding, errors)
except UnicodeDecodeError:
return data.decode(encoding, 'replace')
return data
return data
Loading