From acf5077bacacdd5100e52348a6508a7bf55f9275 Mon Sep 17 00:00:00 2001
From: Alvin Wan
Date: Wed, 8 Apr 2026 00:04:08 -0700
Subject: [PATCH 1/3] reduce scope analysis overhead
---
pymini/pymini.py | 88 +++++++++++++++++-------------------------------
1 file changed, 31 insertions(+), 57 deletions(-)
diff --git a/pymini/pymini.py b/pymini/pymini.py
index e0726d0..cd0cba8 100644
--- a/pymini/pymini.py
+++ b/pymini/pymini.py
@@ -106,6 +106,7 @@ def __init__(self):
self.bindings = set()
self.loads = set()
self.external_bindings = set()
+ self.args = set()
def visit_Name(self, node):
self.reserved_names.add(node.id)
@@ -117,6 +118,7 @@ def visit_Name(self, node):
def visit_arg(self, node):
self.reserved_names.add(node.arg)
self.bindings.add(node.arg)
+ self.args.add(node.arg)
if node.annotation is not None:
self.visit(node.annotation)
@@ -302,6 +304,7 @@ def __init__(
self.class_method_argument_infos = {}
self._class_public_member_reference_cache = {}
self._module_attribute_reference_cache = {}
+ self._scope_analysis_cache = {}
self.modules = set(modules) # don't alias variables imported from these modules
self.keep_global_variables = keep_global_variables
self.rename_arguments = rename_arguments
@@ -676,61 +679,35 @@ def _is_in_function_signature(self, node):
current = getattr(current, "parent", None)
return False
- def _scope_bindings(self, node):
- bindings = set()
- globals_ = set()
- nonlocals_ = set()
- args = set()
-
- class ScopeBindingCollector(ast.NodeVisitor):
- def visit_Global(self, inner):
- globals_.update(inner.names)
-
- def visit_Nonlocal(self, inner):
- nonlocals_.update(inner.names)
-
- def visit_arg(self, inner):
- args.add(inner.arg)
- bindings.add(inner.arg)
-
- def visit_Name(self, inner):
- if isinstance(inner.ctx, ast.Store):
- bindings.add(inner.id)
-
- def visit_FunctionDef(self, inner):
- bindings.add(inner.name)
-
- visit_AsyncFunctionDef = visit_FunctionDef
-
- def visit_ClassDef(self, inner):
- bindings.add(inner.name)
-
- def visit_Lambda(self, inner):
- return None
-
- def visit_ListComp(self, inner):
- return None
-
- def visit_SetComp(self, inner):
- return None
-
- def visit_DictComp(self, inner):
- return None
-
- def visit_GeneratorExp(self, inner):
- return None
+ def _scope_analysis(self, node):
+ cache_key = id(node)
+ cached = self._scope_analysis_cache.get(cache_key)
+ if cached is not None:
+ return cached
- collector = ScopeBindingCollector()
+ collector = ScopeLocalNameCollector()
args_node = getattr(node, "args", None)
if args_node is not None:
collector.visit(args_node)
for statement in getattr(node, "body", []):
- if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
- collector.visit(statement)
- continue
collector.visit(statement)
- bindings.difference_update(globals_ | nonlocals_)
- return {"bindings": bindings, "globals": globals_, "args": args}
+
+ cached = {
+ "reserved_names": frozenset(collector.reserved_names),
+ "bindings": frozenset(collector.bindings - collector.external_bindings),
+ "external_bindings": frozenset(collector.external_bindings),
+ "args": frozenset(collector.args),
+ }
+ self._scope_analysis_cache[cache_key] = cached
+ return cached
+
+ def _scope_bindings(self, node):
+ analysis = self._scope_analysis(node)
+ return {
+ "bindings": set(analysis["bindings"]),
+ "globals": set(analysis["external_bindings"]),
+ "args": set(analysis["args"]),
+ }
def _is_preserved_public_global_reference(self, name):
if name not in self.public_global_names:
@@ -767,13 +744,10 @@ def _is_active_parameter_name(self, name):
return False
def _local_scope_state(self, node):
- collector = ScopeLocalNameCollector()
- collector.visit(node.args)
- for statement in getattr(node, "body", []):
- collector.visit(statement)
- reserved_names = set(collector.reserved_names)
- local_bindings = collector.bindings - collector.external_bindings
- for name in collector.reserved_names - local_bindings:
+ analysis = self._scope_analysis(node)
+ reserved_names = set(analysis["reserved_names"])
+ local_bindings = analysis["bindings"]
+ for name in analysis["reserved_names"] - local_bindings:
visible_name = self._lookup_visible_identifier(name)
if visible_name is not None:
reserved_names.add(visible_name)
@@ -1991,8 +1965,8 @@ def append_public_aliases(tree, aliases):
for node in aliases:
inserted = ast.copy_location(node, root)
inserted._pymini_generated = True
+ ast.fix_missing_locations(inserted)
root.body.append(inserted)
- ast.fix_missing_locations(tree)
class Unparser:
From 34a1254902ba206735e89a1d24866c69ca5156d2 Mon Sep 17 00:00:00 2001
From: Alvin Wan
Date: Wed, 8 Apr 2026 00:43:09 -0700
Subject: [PATCH 2/3] refresh benchmark speed results
---
benchmarks/README.md | 37 ++++++++++++++++++++++---------------
1 file changed, 22 insertions(+), 15 deletions(-)
diff --git a/benchmarks/README.md b/benchmarks/README.md
index ef603bd..6a1807e 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -97,30 +97,35 @@ Wheel-specific failures:
| Input | pymini | pyminifier | python-minifier |
| --- | ---: | ---: | ---: |
-| pyminifier.py | 11.8 ms | 1.7 ms | 7.5 ms |
-| pyminify.py | 25.3 ms | 4.4 ms | 24.2 ms |
-| click | 3.529 s | failed | 914.4 ms |
-| pytest | 15.592 s | failed | 4.567 s |
-| TexSoup | 124.9 ms | 52.2 ms | 117.2 ms |
-| timefhuman | 352.0 ms | 71.0 ms | 266.0 ms |
-| pyminifier | 137.1 ms | 35.6 ms | 114.8 ms |
-| rich | 3.287 s | failed | 1.839 s |
+| pyminifier.py | 3.0 ms | 0.8 ms | 2.6 ms |
+| pyminify.py | 7.9 ms | 2.0 ms | 6.7 ms |
+| click | 855.2 ms | failed | 823.9 ms |
+| pytest | 3.258 s | failed | 3.156 s |
+| TexSoup | 218.6 ms | failed | 251.1 ms |
+| timefhuman | 441.5 ms | failed | 450.6 ms |
+| pyminifier | 196.7 ms | 68.5 ms | 191.7 ms |
+| rich | 3.226 s | failed | 3.080 s |
Speed failures:
+- TexSoup + pyminifier: minification fails on `__init__.py` with
+ `TypeError: 'NoneType' object is not subscriptable`.
- click + pyminifier: minification fails on `click/__init__.py` with
`TypeError: 'NoneType' object is not subscriptable`.
- pytest + pyminifier: minification fails on `_pytest/_argcomplete.py` with
`TypeError: 'NoneType' object is not subscriptable`.
-- rich + pyminifier: the same minification failure prevents a timing result.
+- rich + pyminifier: minification fails on `rich/__init__.py` with
+ `TypeError: 'NoneType' object is not subscriptable`.
+- timefhuman + pyminifier: minification fails on `renderers.py` with
+ `TypeError: 'NoneType' object is not subscriptable`.
The single-file rows come from [benchmark_speed.py](./benchmark_speed.py). The
-package rows are one-shot package minification timings from the same
-environment used for the compression comparison. The `click` and `pytest`
-rows were measured on the checked-in fixtures under `.bench-repos`; `pymini`
-used package mode with `--rename-modules --rename-global-variables
---rename-arguments`, while the baseline tools minified each file independently
-in the preserved package tree.
+package rows are averages of three fresh-process runs from the same
+environment used for the compression comparison. `pymini` used package mode
+with `--rename-modules --rename-global-variables --rename-arguments`, while
+the baseline tools minified each file independently in the preserved package
+tree. The `click`, `pytest`, and `pyminifier` rows use the checked-in fixtures
+under `.bench-repos`; the other package rows use local package checkouts.
# Reproduce
@@ -138,6 +143,8 @@ The larger package comparisons in this file were run against these checkouts:
- `timefhuman`
- `pyminifier`
- `rich`
+- `click`
+- `pytest`
# Validation
From a2de715a772a311f5cbb027158dfca07c4cb7608 Mon Sep 17 00:00:00 2001
From: Alvin Wan
Date: Wed, 8 Apr 2026 00:53:10 -0700
Subject: [PATCH 3/3] tighten benchmark failure note
---
benchmarks/README.md | 12 +-----------
1 file changed, 1 insertion(+), 11 deletions(-)
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 6a1807e..cb270f2 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -106,17 +106,7 @@ Wheel-specific failures:
| pyminifier | 196.7 ms | 68.5 ms | 191.7 ms |
| rich | 3.226 s | failed | 3.080 s |
-Speed failures:
-
-- TexSoup + pyminifier: minification fails on `__init__.py` with
- `TypeError: 'NoneType' object is not subscriptable`.
-- click + pyminifier: minification fails on `click/__init__.py` with
- `TypeError: 'NoneType' object is not subscriptable`.
-- pytest + pyminifier: minification fails on `_pytest/_argcomplete.py` with
- `TypeError: 'NoneType' object is not subscriptable`.
-- rich + pyminifier: minification fails on `rich/__init__.py` with
- `TypeError: 'NoneType' object is not subscriptable`.
-- timefhuman + pyminifier: minification fails on `renderers.py` with
+pyminifier minification fails on `__init__.py` with
`TypeError: 'NoneType' object is not subscriptable`.
The single-file rows come from [benchmark_speed.py](./benchmark_speed.py). The