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
39 changes: 18 additions & 21 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,25 @@ 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 |

Speed failures:

- click + pyminifier: minification fails on `click/__init__.py` with
| 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 |

pyminifier minification fails on `__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.

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

Expand All @@ -138,6 +133,8 @@ The larger package comparisons in this file were run against these checkouts:
- `timefhuman`
- `pyminifier`
- `rich`
- `click`
- `pytest`

# Validation

Expand Down
88 changes: 31 additions & 57 deletions pymini/pymini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading