From a89590efad4a3592eb5062fb24c76e212bf4cd8c Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 11:20:59 -0600 Subject: [PATCH 01/10] Improvement: Better dangling expression check --- personal_python_ast_optimizer/parser/skipper.py | 16 +++++----------- personal_python_ast_optimizer/parser/utils.py | 13 ------------- tests/parser/test_script.py | 8 ++++---- version.txt | 2 +- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index ab9f5d7..e02bf0f 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -23,7 +23,6 @@ is_return_none, remove_duplicate_slots, skip_base_classes, - skip_dangling_expressions, skip_decorators, ) @@ -103,7 +102,11 @@ def generic_visit(self, node: ast.AST) -> ast.AST: for value in old_value: if isinstance(value, ast.AST): value = self.visit(value) # noqa: PLW2901 - if value is None: + if value is None or ( + self.token_types_config.skip_dangling_expressions + and isinstance(value, ast.Expr) + and isinstance(value.value, ast.Constant) + ): continue if not isinstance(value, ast.AST): new_values.extend(value) @@ -154,9 +157,6 @@ def _combine_imports(body: list) -> None: body[:] = new_body def visit_Module(self, node: ast.Module) -> ast.AST: - if self.token_types_config.skip_dangling_expressions: - skip_dangling_expressions(node) - self.generic_visit(node) if self._simplified_named_tuple: @@ -189,9 +189,6 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST | None: if self._use_version_optimization((3, 0)): skip_base_classes(node, ["object"]) - if self.token_types_config.skip_dangling_expressions: - skip_dangling_expressions(node) - skip_base_classes(node, self.tokens_config.classes_to_skip) skip_decorators(node, self.tokens_config.decorators_to_skip) @@ -272,9 +269,6 @@ def _handle_function_node( if self.token_types_config.skip_type_hints: node.returns = None - if self.token_types_config.skip_dangling_expressions: - skip_dangling_expressions(node) - skip_decorators(node, self.tokens_config.decorators_to_skip) if node.body: diff --git a/personal_python_ast_optimizer/parser/utils.py b/personal_python_ast_optimizer/parser/utils.py index 988fd25..a8ee65a 100644 --- a/personal_python_ast_optimizer/parser/utils.py +++ b/personal_python_ast_optimizer/parser/utils.py @@ -37,19 +37,6 @@ def is_return_none(node: ast.Return) -> bool: return isinstance(node.value, ast.Constant) and node.value.value is None -def skip_dangling_expressions( - node: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, -) -> None: - """Removes constant dangling expression like doc strings""" - node.body = [ - element - for element in node.body - if not ( - isinstance(element, ast.Expr) and isinstance(element.value, ast.Constant) - ) - ] - - def skip_base_classes( node: ast.ClassDef, classes_to_ignore: Iterable[str] | TokensToSkip ) -> None: diff --git a/tests/parser/test_script.py b/tests/parser/test_script.py index 395826f..33bf82f 100644 --- a/tests/parser/test_script.py +++ b/tests/parser/test_script.py @@ -17,14 +17,14 @@ def test_one_line_if(): """ 'a' if 'True' == b else 'b' 'a' if b == 'True' else 'b' -'a' if 1==1 else 'b' -'a' if 1==2 else 'b' +a='a' if 1==1 else 'b' +b='a' if 1==2 else 'b' """, """ 'a'if'True'==b else'b' 'a'if b=='True'else'b' -'a' -'b' +a='a' +b='b' """.strip(), ) run_minifier_and_assert_correct(before_and_after) diff --git a/version.txt b/version.txt index dfda3e0..f3b5af3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -6.1.0 +6.1.1 From 51b9d5ad17e5555fa4533b47f5cf8354f35f836a Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 12:53:16 -0600 Subject: [PATCH 02/10] Mostly working --- .../parser/skipper.py | 32 ++++++++++--------- tests/parser/test_assert.py | 16 ++++++++++ tests/parser/test_if.py | 16 +++++++--- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index e02bf0f..920ee40 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -292,9 +292,9 @@ def _should_skip_function( def visit_Try(self, node: ast.Try) -> ast.AST | list[ast.stmt] | None: parsed_node = self.generic_visit(node) - if isinstance( - parsed_node, (ast.Try, ast.TryStar) - ) and self._is_useless_try_node(parsed_node): + if isinstance(parsed_node, (ast.Try, ast.TryStar)) and self._body_is_only_pass( + parsed_node.body + ): return parsed_node.finalbody or None return parsed_node @@ -302,16 +302,16 @@ def visit_Try(self, node: ast.Try) -> ast.AST | list[ast.stmt] | None: def visit_TryStar(self, node: ast.TryStar) -> ast.AST | list[ast.stmt] | None: parsed_node = self.generic_visit(node) - if isinstance( - parsed_node, (ast.Try, ast.TryStar) - ) and self._is_useless_try_node(parsed_node): + if isinstance(parsed_node, (ast.Try, ast.TryStar)) and self._body_is_only_pass( + parsed_node.body + ): return parsed_node.finalbody or None return parsed_node @staticmethod - def _is_useless_try_node(node: ast.Try | ast.TryStar) -> bool: - return all(isinstance(n, ast.Pass) for n in node.body) + def _body_is_only_pass(node_body: list[ast.stmt]) -> bool: + return all(isinstance(n, ast.Pass) for n in node_body) def visit_Attribute(self, node: ast.Attribute) -> ast.AST | None: if isinstance(node.value, ast.Name): @@ -491,13 +491,15 @@ def visit_Dict(self, node: ast.Dict) -> ast.AST: def visit_If(self, node: ast.If) -> ast.AST | list[ast.stmt] | None: parsed_node: ast.AST = self.generic_visit(node) - if isinstance(parsed_node, ast.If) and isinstance( - parsed_node.test, ast.Constant - ): - if_body: list[ast.stmt] = ( - parsed_node.body if parsed_node.test.value else parsed_node.orelse - ) - return if_body or None + if isinstance(parsed_node, ast.If): + if isinstance(parsed_node.test, ast.Constant): + if_body: list[ast.stmt] = ( + parsed_node.body if parsed_node.test.value else parsed_node.orelse + ) + return if_body or None + + if not parsed_node.orelse and self._body_is_only_pass(parsed_node.body): + return parsed_node.test return parsed_node diff --git a/tests/parser/test_assert.py b/tests/parser/test_assert.py index df1397f..39d7b3e 100644 --- a/tests/parser/test_assert.py +++ b/tests/parser/test_assert.py @@ -1,3 +1,4 @@ +from personal_python_ast_optimizer.parser.config import TokenTypesConfig from tests.utils import BeforeAndAfter, run_minifier_and_assert_correct @@ -13,3 +14,18 @@ def test_foo(): ) run_minifier_and_assert_correct(before_and_after) + + +def test_skip_assert(): + before_and_after = BeforeAndAfter( + """ +while 1: + assert val + foo() +""", + "while 1:foo()", + ) + + run_minifier_and_assert_correct( + before_and_after, token_types_config=TokenTypesConfig(skip_asserts=True) + ) diff --git a/tests/parser/test_if.py b/tests/parser/test_if.py index 0103df3..f73efc5 100644 --- a/tests/parser/test_if.py +++ b/tests/parser/test_if.py @@ -5,17 +5,17 @@ _if_cases = [ BeforeAndAfter( """ -if a() == b:pass +if a() == b:eggs() else:pass """, - "if a()==b:pass", + "if a()==b:eggs()", ), BeforeAndAfter( """ -if a == b:pass +if a == b:eggs() else:print()""", """ -if a==b:pass +if a==b:eggs() else:print() """.strip(), ), @@ -56,6 +56,14 @@ else:bar()""", "foo()", ), + BeforeAndAfter( + "if test():pass\nelse:foo()", + "if test():pass\nelse:foo()", + ), + BeforeAndAfter( + "if test():pass", + "test()", + ), ] From 20729385ffa450aa425822907e411e6c87a85b02 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 13:03:13 -0600 Subject: [PATCH 03/10] Improvement: remove useless while nodes and replace while true with while 1 --- .../parser/skipper.py | 16 ++++++++++++++++ tests/parser/test_while.py | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/parser/test_while.py diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index 920ee40..e6ae302 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -513,6 +513,22 @@ def visit_IfExp(self, node: ast.IfExp) -> ast.AST | None: return parsed_node + def visit_While(self, node: ast.While) -> ast.AST | None: + parsed_node = self.generic_visit(node) + + if isinstance(parsed_node, ast.While) and isinstance( + parsed_node.test, ast.Constant + ): + if not parsed_node.test.value: + return None + + # 1 is faster in python 2, same in 3 + # but less size + if parsed_node.test.value is True: + parsed_node.test.value = 1 + + return parsed_node + def visit_Return(self, node: ast.Return) -> ast.AST: if is_return_none(node): node.value = None diff --git a/tests/parser/test_while.py b/tests/parser/test_while.py new file mode 100644 index 0000000..f108045 --- /dev/null +++ b/tests/parser/test_while.py @@ -0,0 +1,19 @@ +from tests.utils import BeforeAndAfter, run_minifier_and_assert_correct + + +def test_useless_while(): + before_and_after = BeforeAndAfter( + "while 0:\n\tfoo()", + "", + ) + + run_minifier_and_assert_correct(before_and_after) + + +def test_while_true(): + before_and_after = BeforeAndAfter( + "while True:\n\tfoo()", + "while 1:foo()", + ) + + run_minifier_and_assert_correct(before_and_after) From 0b6f8202c64eb8ceca8caa384907b6135d8e4c51 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 13:13:50 -0600 Subject: [PATCH 04/10] Fix expr --- personal_python_ast_optimizer/parser/skipper.py | 2 +- tests/parser/test_if.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index e6ae302..9f5b1de 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -499,7 +499,7 @@ def visit_If(self, node: ast.If) -> ast.AST | list[ast.stmt] | None: return if_body or None if not parsed_node.orelse and self._body_is_only_pass(parsed_node.body): - return parsed_node.test + return ast.Expr(parsed_node.test) return parsed_node diff --git a/tests/parser/test_if.py b/tests/parser/test_if.py index f73efc5..cc846b5 100644 --- a/tests/parser/test_if.py +++ b/tests/parser/test_if.py @@ -64,6 +64,13 @@ "if test():pass", "test()", ), + BeforeAndAfter( + """ +try:foo() +except:raise OSError +if test():pass""", + "try:foo()\nexcept:raise OSError\ntest()", + ), ] From 85a197d37faa3637c4233717cf9c6e5aa9196fe3 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 13:21:17 -0600 Subject: [PATCH 05/10] TODO --- personal_python_ast_optimizer/parser/skipper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index 9f5b1de..04c7fcb 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -105,6 +105,7 @@ def generic_visit(self, node: ast.AST) -> ast.AST: if value is None or ( self.token_types_config.skip_dangling_expressions and isinstance(value, ast.Expr) + # TODO: handle binop/boolop/compare with only constants or names and isinstance(value.value, ast.Constant) ): continue From 900e252a647aff837456c29e13e2d8c7a6cff4d4 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 17:46:31 -0600 Subject: [PATCH 06/10] Working I think --- .../parser/config.py | 12 +++++- .../parser/skipper.py | 39 +++++++++++++++++-- personal_python_ast_optimizer/python_info.py | 6 +++ tests/parser/test_if.py | 8 ++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/personal_python_ast_optimizer/parser/config.py b/personal_python_ast_optimizer/parser/config.py index 2160a92..559d12b 100644 --- a/personal_python_ast_optimizer/parser/config.py +++ b/personal_python_ast_optimizer/parser/config.py @@ -3,6 +3,10 @@ from enum import Enum, EnumType from types import EllipsisType +from personal_python_ast_optimizer.python_info import ( + default_functions_safe_to_exclude_in_test_expr, +) + class TypeHintsToSkip(Enum): NONE = 0 @@ -132,18 +136,20 @@ class OptimizationsConfig(_Config): __slots__ = ( "vars_to_fold", "enums_to_fold", + "functions_safe_to_exclude_in_test_expr", "remove_unused_imports", "fold_constants", "assume_this_machine", ) - def __init__( + def __init__( # noqa: PLR0913 self, vars_to_fold: dict[ str, str | bytes | bool | int | float | complex | None | EllipsisType ] | None = None, enums_to_fold: Iterable[EnumType] | None = None, + functions_safe_to_exclude_in_test_expr: set[str] | None = None, fold_constants: bool = True, remove_unused_imports: bool = True, assume_this_machine: bool = False, @@ -156,6 +162,10 @@ def __init__( if enums_to_fold is None else self._format_enums_to_fold_as_dict(enums_to_fold) ) + self.functions_safe_to_exclude_in_test_expr: set[str] = ( + functions_safe_to_exclude_in_test_expr + or default_functions_safe_to_exclude_in_test_expr + ) self.remove_unused_imports: bool = remove_unused_imports self.assume_this_machine: bool = assume_this_machine self.fold_constants: bool = fold_constants diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index 04c7fcb..45a18b6 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -25,6 +25,9 @@ skip_base_classes, skip_decorators, ) +from personal_python_ast_optimizer.python_info import ( + default_functions_safe_to_exclude_in_test_expr, +) class _NodeContext(Enum): @@ -101,17 +104,19 @@ def generic_visit(self, node: ast.AST) -> ast.AST: self._combine_imports(old_value) for value in old_value: if isinstance(value, ast.AST): - value = self.visit(value) # noqa: PLW2901 + value: ast.AST | list[ast.AST] | None = self.visit(value) # noqa: PLW2901 + if value is None or ( self.token_types_config.skip_dangling_expressions and isinstance(value, ast.Expr) - # TODO: handle binop/boolop/compare with only constants or names and isinstance(value.value, ast.Constant) ): continue + if not isinstance(value, ast.AST): new_values.extend(value) continue + new_values.append(value) if ( @@ -500,7 +505,11 @@ def visit_If(self, node: ast.If) -> ast.AST | list[ast.stmt] | None: return if_body or None if not parsed_node.orelse and self._body_is_only_pass(parsed_node.body): - return ast.Expr(parsed_node.test) + call_finder = _DanglingExprCallFinder( + self.optimizations_config.functions_safe_to_exclude_in_test_expr + ) + call_finder.visit(parsed_node.test) + return [ast.Expr(expr) for expr in call_finder.calls] return parsed_node @@ -566,6 +575,9 @@ def visit_Call(self, node: ast.Call) -> ast.AST | None: return self.generic_visit(node) + def visit_Constant(self, node: ast.Constant) -> ast.Constant: + return node + def visit_Expr(self, node: ast.Expr) -> ast.AST | None: if ( isinstance(node.value, ast.Call) @@ -824,3 +836,24 @@ def visit_Break(self, node: ast.Break) -> ast.Break: def visit_Continue(self, node: ast.Continue) -> ast.Continue: return node + + def visit_Constant(self, node: ast.Constant) -> ast.Constant: + return node + + +class _DanglingExprCallFinder(ast.NodeTransformer): + """Finds all calls in a given dangling expression + execpt for a subset of builtin functions that have + no side effects.""" + + __slots__ = ("calls", "excludes") + + def __init__(self, excludes: set[str]) -> None: + self.calls: list[ast.Call] = [] + self.excludes: set[str] = excludes + + def visit_Call(self, node: ast.Call) -> ast.Call: + if get_node_name(node) not in default_functions_safe_to_exclude_in_test_expr: + self.calls.append(node) + + return node diff --git a/personal_python_ast_optimizer/python_info.py b/personal_python_ast_optimizer/python_info.py index c8ade66..83b2664 100644 --- a/personal_python_ast_optimizer/python_info.py +++ b/personal_python_ast_optimizer/python_info.py @@ -1,5 +1,11 @@ """Various tokens in Python that the ast module writes""" +# Functions that have no sideeffects and thus are safe to remove +# if a test expression is found to be useless. For example: +# if "str(a) == 'a':pass" will be turned into just "str(a) == 'a'" +# but if its known str has no side effects then it can be fully removed +default_functions_safe_to_exclude_in_test_expr: set[str] = {"int", "str", "isinstance"} + comparison_and_conjunctions: list[str] = [ " if ", " else ", diff --git a/tests/parser/test_if.py b/tests/parser/test_if.py index cc846b5..19aa29e 100644 --- a/tests/parser/test_if.py +++ b/tests/parser/test_if.py @@ -64,6 +64,14 @@ "if test():pass", "test()", ), + BeforeAndAfter( + "if str(a) == 'a':pass", + "", + ), + BeforeAndAfter( + "if a < 3:pass", + "", + ), BeforeAndAfter( """ try:foo() From b299bba1b74ae3ceabe4a471e4168d97d405b4eb Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 20:00:15 -0600 Subject: [PATCH 07/10] add more to defaults --- personal_python_ast_optimizer/python_info.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/personal_python_ast_optimizer/python_info.py b/personal_python_ast_optimizer/python_info.py index 83b2664..d526632 100644 --- a/personal_python_ast_optimizer/python_info.py +++ b/personal_python_ast_optimizer/python_info.py @@ -1,10 +1,16 @@ """Various tokens in Python that the ast module writes""" -# Functions that have no sideeffects and thus are safe to remove +# Functions that have no side effects and thus are safe to remove # if a test expression is found to be useless. For example: # if "str(a) == 'a':pass" will be turned into just "str(a) == 'a'" # but if its known str has no side effects then it can be fully removed -default_functions_safe_to_exclude_in_test_expr: set[str] = {"int", "str", "isinstance"} +default_functions_safe_to_exclude_in_test_expr: set[str] = { + "int", + "str", + "isinstance", + "getattr", + "hasattr", +} comparison_and_conjunctions: list[str] = [ " if ", From b945a3c43cc7e6500c60b260c6a1f97547556318 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 20:09:16 -0600 Subject: [PATCH 08/10] Slightly longer test --- tests/parser/test_if.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/parser/test_if.py b/tests/parser/test_if.py index 19aa29e..6fb2af5 100644 --- a/tests/parser/test_if.py +++ b/tests/parser/test_if.py @@ -61,7 +61,7 @@ "if test():pass\nelse:foo()", ), BeforeAndAfter( - "if test():pass", + "if test():pass\nelse:pass", "test()", ), BeforeAndAfter( From 06082b752fc1ec0a8f7bc2adcc1fb746cfd7ffa6 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 20:23:05 -0600 Subject: [PATCH 09/10] remove type hint --- personal_python_ast_optimizer/parser/skipper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index 210c90b..8bf2648 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -104,7 +104,7 @@ def generic_visit(self, node: ast.AST) -> ast.AST: self._combine_imports(old_value) for value in old_value: if isinstance(value, ast.AST): - value: ast.AST | list[ast.AST] | None = self.visit(value) # noqa: PLW2901 + value = self.visit(value) # noqa: PLW2901 if value is None or ( self.token_types_config.skip_dangling_expressions From 0b830631122bed06f3f9e09116271887aeec30b9 Mon Sep 17 00:00:00 2001 From: jbjd Date: Sat, 17 Jan 2026 20:25:12 -0600 Subject: [PATCH 10/10] Codespell --- personal_python_ast_optimizer/parser/skipper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/personal_python_ast_optimizer/parser/skipper.py b/personal_python_ast_optimizer/parser/skipper.py index 8bf2648..07c3b2e 100644 --- a/personal_python_ast_optimizer/parser/skipper.py +++ b/personal_python_ast_optimizer/parser/skipper.py @@ -842,7 +842,7 @@ def visit_Constant(self, node: ast.Constant) -> ast.Constant: class _DanglingExprCallFinder(ast.NodeTransformer): """Finds all calls in a given dangling expression - execpt for a subset of builtin functions that have + except for a subset of builtin functions that have no side effects.""" __slots__ = ("calls", "excludes")