diff --git a/.config/ruff.toml b/.config/ruff.toml index 8024abc..a586089 100644 --- a/.config/ruff.toml +++ b/.config/ruff.toml @@ -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"] @@ -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"] diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 91f3cf8..4e7c549 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c9853..2c31121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index 9d0b685..4566cd4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/corpus_test/generate_report.py b/corpus_test/generate_report.py index 7da3fbb..c311478 100644 --- a/corpus_test/generate_report.py +++ b/corpus_test/generate_report.py @@ -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: """ @@ -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(): diff --git a/hypo_test/expressions.py b/hypo_test/expressions.py index 4e4034b..3031c3a 100644 --- a/hypo_test/expressions.py +++ b/hypo_test/expressions.py @@ -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 @@ -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 diff --git a/hypo_test/module.py b/hypo_test/module.py index 14160c4..a57f74d 100644 --- a/hypo_test/module.py +++ b/hypo_test/module.py @@ -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 diff --git a/hypo_test/patterns.py b/hypo_test/patterns.py index 7fef56a..f5d9aeb 100644 --- a/hypo_test/patterns.py +++ b/hypo_test/patterns.py @@ -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 @@ -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: @@ -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( diff --git a/hypo_test/strings.py b/hypo_test/strings.py index 59d688c..64a1f45 100644 --- a/hypo_test/strings.py +++ b/hypo_test/strings.py @@ -1,6 +1,5 @@ import ast -from hypothesis import assume from hypothesis.strategies import ( SearchStrategy, booleans, diff --git a/src/python_minifier/ast_compare.py b/src/python_minifier/ast_compare.py index 470f689..17c1eb5 100644 --- a/src/python_minifier/ast_compare.py +++ b/src/python_minifier/ast_compare.py @@ -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 diff --git a/src/python_minifier/ast_compat.py b/src/python_minifier/ast_compat.py index c9c31c7..c2d68be 100644 --- a/src/python_minifier/ast_compat.py +++ b/src/python_minifier/ast_compat.py @@ -77,4 +77,4 @@ def __new__(cls, *args, **kwargs): 'withitem', ]: if _node_type not in globals(): - globals()[_node_type] = type(_node_type, (AST,), {}) \ No newline at end of file + globals()[_node_type] = type(_node_type, (AST,), {}) diff --git a/src/python_minifier/expression_printer.py b/src/python_minifier/expression_printer.py index bfdb45b..c9ea66f 100644 --- a/src/python_minifier/expression_printer.py +++ b/src/python_minifier/expression_printer.py @@ -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) diff --git a/src/python_minifier/module_printer.py b/src/python_minifier/module_printer.py index e157465..0e8468d 100644 --- a/src/python_minifier/module_printer.py +++ b/src/python_minifier/module_printer.py @@ -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) @@ -626,10 +626,10 @@ 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) @@ -637,7 +637,7 @@ def visit_MatchSequence(self, node, open=False): 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): diff --git a/src/python_minifier/rename/renamer.py b/src/python_minifier/rename/renamer.py index 1630c5f..fb8ad25 100644 --- a/src/python_minifier/rename/renamer.py +++ b/src/python_minifier/rename/renamer.py @@ -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) diff --git a/src/python_minifier/token_printer.py b/src/python_minifier/token_printer.py index 8e4588e..32f2d48 100644 --- a/src/python_minifier/token_printer.py +++ b/src/python_minifier/token_printer.py @@ -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 diff --git a/test/ast_annotation/test_add_parent.py b/test/ast_annotation/test_add_parent.py index 83e434e..c21447b 100644 --- a/test/ast_annotation/test_add_parent.py +++ b/test/ast_annotation/test_add_parent.py @@ -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]) diff --git a/test/conftest.py b/test/conftest.py index 8e345de..f394a1b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -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') \ No newline at end of file +os.environ.setdefault('PYMINIFY_FORCE_BEST_EFFORT', '1') diff --git a/test/subprocess_compat.py b/test/subprocess_compat.py index 0c1890e..1308fa2 100644 --- a/test/subprocess_compat.py +++ b/test/subprocess_compat.py @@ -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'): @@ -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 \ No newline at end of file + return data diff --git a/test/test_best_effort_cli.py b/test/test_best_effort_cli.py index d4104a2..fa6913c 100644 --- a/test/test_best_effort_cli.py +++ b/test/test_best_effort_cli.py @@ -17,24 +17,24 @@ def hello_world(): if __name__ == "__main__": hello_world() ''' - + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(code) temp_file = f.name - + try: env = os.environ.copy() env.pop('PYMINIFY_FORCE_BEST_EFFORT', None) - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', temp_file ], timeout=30, env=env) - + assert result.returncode == 0 - + stdout_text = safe_decode(result.stdout) assert len(stdout_text) < len(code) - + finally: os.unlink(temp_file) @@ -42,24 +42,24 @@ def hello_world(): def test_returns_original_when_longer(): """Test CLI returns original code when minified output would be longer.""" code = 'True if 0in x else False' - + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(code) temp_file = f.name - + try: env = os.environ.copy() env.pop('PYMINIFY_FORCE_BEST_EFFORT', None) - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', temp_file ], timeout=30, env=env) - + assert result.returncode == 0 - + stdout_text = safe_decode(result.stdout) assert stdout_text == code - + finally: os.unlink(temp_file) @@ -68,24 +68,24 @@ def test_force_minified_with_env_var(): """Test environment variable forces minified output regardless of size.""" code = 'True if 0in x else False' expected_output = 'True if 0 in x else False' - + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(code) temp_file = f.name - + try: env = os.environ.copy() env['PYMINIFY_FORCE_BEST_EFFORT'] = '1' - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', temp_file ], timeout=30, env=env) - + assert result.returncode == 0 - + stdout_text = safe_decode(result.stdout) assert stdout_text == expected_output - + finally: os.unlink(temp_file) @@ -94,26 +94,26 @@ def test_stdin_behavior(): """Test size-based logic works with stdin input.""" code = 'True if 0in x else False' expected_output = 'True if 0 in x else False' - + # Without env var - should return original env = os.environ.copy() env.pop('PYMINIFY_FORCE_BEST_EFFORT', None) - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', '-' ], input_data=code, timeout=30, env=env) - + assert result.returncode == 0 stdout_text = safe_decode(result.stdout) assert stdout_text == code - + # With env var - should return minified env['PYMINIFY_FORCE_BEST_EFFORT'] = '1' - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', '-' ], input_data=code, timeout=30, env=env) - + assert result.returncode == 0 stdout_text = safe_decode(result.stdout) assert stdout_text == expected_output @@ -122,30 +122,30 @@ def test_stdin_behavior(): def test_output_file_behavior(): """Test size-based logic works with --output flag.""" code = 'True if 0in x else False' - + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as input_file: input_file.write(code) input_filename = input_file.name - + with tempfile.NamedTemporaryFile(delete=False) as output_file: output_filename = output_file.name - + try: env = os.environ.copy() env.pop('PYMINIFY_FORCE_BEST_EFFORT', None) - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', input_filename, '--output', output_filename ], timeout=30, env=env) - + assert result.returncode == 0 - + with open(output_filename, 'r') as f: output_content = f.read() - + assert output_content == code - + finally: os.unlink(input_filename) os.unlink(output_filename) @@ -154,26 +154,26 @@ def test_output_file_behavior(): def test_in_place_behavior(): """Test size-based logic works with --in-place flag.""" code = 'True if 0in x else False' - + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(code) temp_file = f.name - + try: env = os.environ.copy() env.pop('PYMINIFY_FORCE_BEST_EFFORT', None) - + result = run_subprocess([ sys.executable, '-m', 'python_minifier', temp_file, '--in-place' ], timeout=30, env=env) - + assert result.returncode == 0 - + with open(temp_file, 'r') as f: modified_content = f.read() - + assert modified_content == code - + finally: - os.unlink(temp_file) \ No newline at end of file + os.unlink(temp_file) diff --git a/test/test_bind_names_python33.py b/test/test_bind_names_python33.py index 8a8f3da..f72dd1a 100644 --- a/test/test_bind_names_python33.py +++ b/test/test_bind_names_python33.py @@ -6,7 +6,7 @@ def test_module_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -24,7 +24,7 @@ def test_module_namespace(): def test_lambda_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -47,7 +47,7 @@ def test_lambda_namespace(): def test_function_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -84,7 +84,7 @@ def inner_func(): # region generator namespace def test_generator_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -103,7 +103,7 @@ def test_generator_namespace(): def test_multi_generator_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -125,7 +125,7 @@ def test_multi_generator_namespace(): def test_multi_generator_namespace_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -150,7 +150,7 @@ def test_multi_generator_namespace_2(): def test_nested_generator(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -176,7 +176,7 @@ def test_nested_generator(): def test_nested_generator_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -203,7 +203,7 @@ def test_nested_generator_2(): # region setcomp def test_setcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -222,7 +222,7 @@ def test_setcomp_namespace(): def test_multi_setcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -244,7 +244,7 @@ def test_multi_setcomp_namespace(): def test_multi_setcomp_namespace_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -269,7 +269,7 @@ def test_multi_setcomp_namespace_2(): def test_nested_setcomp(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -295,7 +295,7 @@ def test_nested_setcomp(): def test_nested_setcomp_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -321,7 +321,7 @@ def test_nested_setcomp_2(): # region listcomp def test_listcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -340,7 +340,7 @@ def test_listcomp_namespace(): def test_multi_listcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -362,7 +362,7 @@ def test_multi_listcomp_namespace(): def test_multi_listcomp_namespace_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -387,7 +387,7 @@ def test_multi_listcomp_namespace_2(): def test_nested_listcomp(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -413,7 +413,7 @@ def test_nested_listcomp(): def test_nested_listcomp_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -439,7 +439,7 @@ def test_nested_listcomp_2(): # region dictcomp def test_dictcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -458,7 +458,7 @@ def test_dictcomp_namespace(): def test_multi_dictcomp_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -480,7 +480,7 @@ def test_multi_dictcomp_namespace(): def test_multi_dictcomp_namespace_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -505,7 +505,7 @@ def test_multi_dictcomp_namespace_2(): def test_nested_dictcomp(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -531,7 +531,7 @@ def test_nested_dictcomp(): def test_nested_dictcomp_2(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -555,7 +555,7 @@ def test_nested_dictcomp_2(): # endregion def test_class_namespace(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' @@ -589,7 +589,7 @@ class C: def test_class_name_rebinding(): - if sys.version_info < (3, 3) or sys.version_info > (3, 4): + if sys.version_info < (3, 3) or sys.version_info >= (3, 4): pytest.skip('Test is for python3.3 only') source = ''' diff --git a/test/test_fstring.py b/test/test_fstring.py index 0c9897f..b634f3d 100644 --- a/test/test_fstring.py +++ b/test/test_fstring.py @@ -47,8 +47,8 @@ def test_pep0701(): ''' assert unparse(ast.parse(statement)) == 'f"This is the playlist: {", ".join(["Take me back to Eden","Alkaline","Ascensionism"])}"' - # statement = '''print(f"This is the playlist: {"\N{BLACK HEART SUIT}".join(songs)}")''' - # assert unparse(ast.parse(statement)) == statement + statement = '''print(f"This is the playlist: {"\N{BLACK HEART SUIT}".join(songs)}")''' + assert unparse(ast.parse(statement)) == statement statement = '''f"Magic wand: {bag["wand"]}"''' assert unparse(ast.parse(statement)) == statement @@ -78,9 +78,9 @@ def test_pep0701(): statement = '''f"{"":*^{1:{1}}}"''' assert unparse(ast.parse(statement)) == statement - # statement = '''f"{"":*^{1:{1:{1}}}}"''' - # assert unparse(ast.parse(statement)) == statement - # SyntaxError: f-string: expressions nested too deeply + # Verify that Python raises SyntaxError for f-strings nested too deeply + with pytest.raises(SyntaxError, match="expressions nested too deeply"): + ast.parse('''f"{"":*^{1:{1:{1}}}}"''') statement = '''f"___{ x diff --git a/test/test_is_constant_node.py b/test/test_is_constant_node.py index f32a990..d92b54c 100644 --- a/test/test_is_constant_node.py +++ b/test/test_is_constant_node.py @@ -8,7 +8,7 @@ @pytest.mark.filterwarnings("ignore:ast.Str is deprecated:DeprecationWarning") -@pytest.mark.filterwarnings("ignore:ast.Bytes is deprecated:DeprecationWarning") +@pytest.mark.filterwarnings("ignore:ast.Bytes is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.Num is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.NameConstant is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.Ellipsis is deprecated:DeprecationWarning") @@ -36,7 +36,7 @@ def test_type_nodes(): @pytest.mark.filterwarnings("ignore:ast.Str is deprecated:DeprecationWarning") -@pytest.mark.filterwarnings("ignore:ast.Bytes is deprecated:DeprecationWarning") +@pytest.mark.filterwarnings("ignore:ast.Bytes is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.Num is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.NameConstant is deprecated:DeprecationWarning") @pytest.mark.filterwarnings("ignore:ast.Ellipsis is deprecated:DeprecationWarning") @@ -61,9 +61,9 @@ def test_ast_compat_types_python314(): """Test that ast_compat provides the removed AST types in Python 3.14+""" if sys.version_info < (3, 14): pytest.skip('ast_compat types test only for Python 3.14+') - + import python_minifier.ast_compat as ast_compat - + # Test that ast_compat provides the removed types assert is_constant_node(ast_compat.Str('a'), ast_compat.Str) assert is_constant_node(ast_compat.Bytes(b'a'), ast_compat.Bytes) @@ -79,9 +79,9 @@ def test_ast_compat_constant_nodes_python314(): """Test that ast_compat works with Constant nodes in Python 3.14+""" if sys.version_info < (3, 14): pytest.skip('ast_compat constant test only for Python 3.14+') - + import python_minifier.ast_compat as ast_compat - + # Test that Constant nodes work with ast_compat types assert is_constant_node(ast.Constant('a'), ast_compat.Str) assert is_constant_node(ast.Constant(b'a'), ast_compat.Bytes) diff --git a/test/test_raw_fstring_backslash.py b/test/test_raw_fstring_backslash.py index 1057c56..7404144 100644 --- a/test/test_raw_fstring_backslash.py +++ b/test/test_raw_fstring_backslash.py @@ -11,7 +11,7 @@ from python_minifier.ast_compare import compare_ast -@pytest.mark.parametrize('source,description', [ +@pytest.mark.parametrize(('source', 'description'), [ # Raw f-string backslash tests - core regression fix pytest.param(r'rf"{x:\\xFF}"', 'Single backslash in format spec (minimal failing case)', id='raw-fstring-backslash-format-spec'), pytest.param(r'rf"\\n{x}\\t"', 'Backslashes in literal parts', id='raw-fstring-backslash-outer-str'), @@ -52,7 +52,7 @@ def test_fstring_edge_cases(source, description): compare_ast(expected_ast, ast.parse(actual_code)) -@pytest.mark.parametrize('source,description', [ +@pytest.mark.parametrize(('source', 'description'), [ pytest.param(r'f"{f"\\n{x}\\t"}"', 'Nested f-strings with backslashes in inner string parts', id='nested-fstring-backslashes'), pytest.param(r'f"{rf"\\xFF{y}\\n"}"', 'Nested raw f-strings with backslashes', id='nested-raw-fstring-backslashes'), pytest.param(r'f"{f"{x:\\xFF}"}"', 'Nested f-strings with backslashes in format specs', id='nested-fstring-format-spec-backslashes'), diff --git a/test/test_raw_tstring_backslash.py b/test/test_raw_tstring_backslash.py index 525face..cddcd2b 100644 --- a/test/test_raw_tstring_backslash.py +++ b/test/test_raw_tstring_backslash.py @@ -16,7 +16,7 @@ from python_minifier.ast_compare import compare_ast -@pytest.mark.parametrize('source,description', [ +@pytest.mark.parametrize(('source', 'description'), [ # Raw t-string backslash tests - core regression testing pytest.param(r'rt"{x:\\xFF}"', 'Single backslash in format spec (minimal case)', id='raw-tstring-backslash-format-spec'), pytest.param(r'rt"\\n{x}\\t"', 'Backslashes in literal parts', id='raw-tstring-backslash-outer-str'), @@ -71,7 +71,7 @@ def test_tstring_edge_cases(source, description): compare_ast(expected_ast, ast.parse(actual_code)) -@pytest.mark.parametrize('source,description', [ +@pytest.mark.parametrize(('source', 'description'), [ pytest.param(r't"{t"\\n{x}\\t"}"', 'Nested t-strings with backslashes in inner string parts', id='nested-tstring-backslashes'), pytest.param(r't"{rt"\\xFF{y}\\n"}"', 'Nested raw t-strings with backslashes', id='nested-raw-tstring-backslashes'), pytest.param(r't"{t"{x:\\xFF}"}"', 'Nested t-strings with backslashes in format specs', id='nested-tstring-format-spec-backslashes'), @@ -95,4 +95,4 @@ def test_tstring_carriage_return_format_spec(): source = 't"{x:\\r}"' expected_ast = ast.parse(source) actual_code = unparse(expected_ast) - compare_ast(expected_ast, ast.parse(actual_code)) \ No newline at end of file + compare_ast(expected_ast, ast.parse(actual_code)) diff --git a/test/test_template_strings.py b/test/test_template_strings.py index 117d7c4..010178d 100644 --- a/test/test_template_strings.py +++ b/test/test_template_strings.py @@ -67,7 +67,7 @@ def test_tstring_quote_variations(): 't"mixed {name} with \\"escaped\\" quotes"', "t'mixed {name} with \\'escaped\\' quotes'", ] - + for statement in statements: # Just test that it parses and round-trips correctly, don't care about exact quote style parsed = ast.parse(statement) @@ -101,7 +101,7 @@ def test_tstring_with_complex_expressions(): ('t"Subscription: {data[key][0]}"', 't"Subscription: {data[key][0]}"'), ('t"Ternary: {x if condition else y}"', 't"Ternary: {x if condition else y}"'), ] - + for input_statement, expected_output in test_cases: assert unparse(ast.parse(input_statement)) == expected_output @@ -119,7 +119,7 @@ def test_tstring_with_binary_operations(): ('t"Boolean: {a and b}"', 't"Boolean: {a and b}"'), ('t"Bitwise: {x | y}"', 't"Bitwise: {x|y}"'), ] - + for input_statement, expected_output in test_cases: assert unparse(ast.parse(input_statement)) == expected_output @@ -149,7 +149,7 @@ def test_tstring_special_characters(): "t'Quote: \\' and {value}'", 't"Backslash: \\\\ and {value}"', ] - + for statement in statements: # Test that it parses and round-trips correctly parsed = ast.parse(statement) @@ -173,14 +173,14 @@ def test_tstring_vs_fstring_syntax(): # These should both parse successfully but produce different ASTs tstring = 't"Hello {name}"' fstring = 'f"Hello {name}"' - + t_ast = ast.parse(tstring) f_ast = ast.parse(fstring) - + # Should be different node types in the expression assert type(t_ast.body[0].value).__name__ == 'TemplateStr' assert type(f_ast.body[0].value).__name__ == 'JoinedStr' - + # But should unparse correctly assert unparse(t_ast) == tstring assert unparse(f_ast) == fstring @@ -195,22 +195,22 @@ def test_raw_template_strings(): 'rt"raw template {name}"', 'rt"backslash \\\\ preserved {name}"', ] - + for statement in raw_statements: # Raw t-strings should parse successfully ast.parse(statement) - + # Test that raw behavior is preserved in the AST even if prefix is lost raw_backslash = 'rt"backslash \\\\n and {name}"' regular_backslash = 't"backslash \\n and {name}"' # Only two backslashes for regular - + raw_ast = ast.parse(raw_backslash) regular_ast = ast.parse(regular_backslash) - + # The AST should show different string content raw_content = raw_ast.body[0].value.values[0].value regular_content = regular_ast.body[0].value.values[0].value - + # Raw should have literal backslash-n, regular should have actual newline assert '\\\\n' in raw_content # literal backslash-n (two chars: \ and n) assert '\n' in regular_content # actual newline character @@ -223,12 +223,12 @@ def test_tstring_debug_specifier_limitations(): # Debug specifiers work when at the start of the string assert unparse(ast.parse('t"{name=}"')) == 't"{name=}"' assert unparse(ast.parse('t"{value=:.2f}"')) == 't"{value=:.2f}"' - + # But are lost when there's a preceding literal (same limitation as f-strings) assert unparse(ast.parse('t"Hello {name=}"')) == 't"Hello name={name!r}"' assert unparse(ast.parse('t"Hello {name=!s}"')) == 't"Hello name={name!s}"' assert unparse(ast.parse('t"Hello {name=:.2f}"')) == 't"Hello name={name:.2f}"' - + # This matches f-string behavior exactly assert unparse(ast.parse('f"Hello {name=}"')) == 'f"Hello name={name!r}"' @@ -242,7 +242,7 @@ def test_tstring_error_conditions(): 't"Format {value:{width}.{precision}f} complex"', 't"Mixed {a!r} and {b=:.2f} specifiers"', ] - + for case in complex_cases: try: # Parse as module, not expression @@ -251,4 +251,4 @@ def test_tstring_error_conditions(): actual_ast = ast.parse(unparsed) compare_ast(expected_ast, actual_ast) except Exception as e: - pytest.fail("Failed to handle complex case {}: {}".format(case, e)) \ No newline at end of file + pytest.fail("Failed to handle complex case {}: {}".format(case, e)) diff --git a/test/test_tuple_with_bug.py b/test/test_tuple_with_bug.py index 5d4eb6d..b95a143 100644 --- a/test/test_tuple_with_bug.py +++ b/test/test_tuple_with_bug.py @@ -31,4 +31,4 @@ def test_nested_tuple_with(): expected_ast = ast.parse(source) minified = unparse(expected_ast) - compare_ast(expected_ast, ast.parse(minified)) \ No newline at end of file + compare_ast(expected_ast, ast.parse(minified)) diff --git a/test/test_utf8_encoding.py b/test/test_utf8_encoding.py index 5403690..9284467 100644 --- a/test/test_utf8_encoding.py +++ b/test/test_utf8_encoding.py @@ -29,7 +29,7 @@ def mathematical_formula(): def arrow_symbols(): directions = { u"left": u"←", - u"right": u"→", + u"right": u"→", u"up": u"↑", u"down": u"↓" } @@ -93,7 +93,7 @@ class UnicodeClass: """Class with unicode: ñ ü ö ä ë ï ÿ""" def __init__(self): self.message = u"Héllö Wörld with àccénts!" - + def get_symbols(self): return u"Symbols: ™ © ® ° ± × ÷ ≠ ≤ ≥" ''' diff --git a/xtest/test_regrtest.py b/xtest/test_regrtest.py index 3c07173..d546a2f 100644 --- a/xtest/test_regrtest.py +++ b/xtest/test_regrtest.py @@ -112,17 +112,15 @@ def get_active_manifest(): if platform.python_implementation() == 'CPython': return Manifest('python%i.%i' % (sys.version_info[0], sys.version_info[1])) - else: - if sys.version_info[0] == 2: - return Manifest('pypy') - else: - return Manifest('pypy3') + if sys.version_info[0] == 2: + return Manifest('pypy') + return Manifest('pypy3') manifest = get_active_manifest() -@pytest.mark.filterwarnings("ignore:This process \(pid=\d+\) is multi-threaded, use of fork\(\) may lead to deadlocks in the child.:DeprecationWarning:sh") +@pytest.mark.filterwarnings(r"ignore:This process \(pid=\d+\) is multi-threaded, use of fork\(\) may lead to deadlocks in the child.:DeprecationWarning:sh") @pytest.mark.parametrize('test_case', list(manifest), ids=lambda test_case: repr(test_case)) def test_regrtest(test_case): test_case.run_test()