shakeout 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shakeout/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """shakeout: a static, tree-shaking bundler for Python."""
2
+
3
+ __version__ = "0.1.0"
shakeout/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m shakeout``."""
2
+
3
+ from shakeout.cli import main
4
+
5
+ raise SystemExit(main())
shakeout/_compat.py ADDED
@@ -0,0 +1,19 @@
1
+ """Small shims for older supported Python versions (no extra runtime dependencies)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ if sys.version_info >= (3, 12):
9
+ from typing import override
10
+ elif TYPE_CHECKING: # type checkers understand typing_extensions; nothing is imported at runtime
11
+ from typing_extensions import override
12
+ else: # pragma: no cover - exercised on 3.10/3.11 only
13
+
14
+ def override(func, /): # noqa: ANN001, ANN202 - typed via typing_extensions above
15
+ """Mark a method as overriding its base (typing.override before 3.12)."""
16
+ return func
17
+
18
+
19
+ __all__ = ["override"]
@@ -0,0 +1 @@
1
+ """Per-statement analysis: scopes, bindings and side effects."""
@@ -0,0 +1,115 @@
1
+ """Side effects of class definitions that come from their bases.
2
+
3
+ Subclassing runs ``__init_subclass__`` and metaclass code of every base, which is how plugin
4
+ registries work. A class is *registering* if it defines ``__init_subclass__``, has a metaclass,
5
+ or inherits from a registering class; defining a subclass of a registering class is a side effect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from typing import TYPE_CHECKING
12
+
13
+ from shakeout.analysis.effects import EffectRules
14
+ from shakeout.analysis.registry import PURE_BASE_MODULES, PURE_BASES
15
+ from shakeout.analysis.scopes import attribute_chain
16
+ from shakeout.model import NameTarget
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ from shakeout.graph import Resolver, StmtKey
22
+ from shakeout.model import Module
23
+
24
+ Verdict = tuple[str | None, bool] # (impurity reason from bases, is registering)
25
+
26
+
27
+ class ClassEffects:
28
+ """Classifies class statements by what their bases do when subclassed."""
29
+
30
+ def __init__(
31
+ self,
32
+ modules: dict[str, Module],
33
+ resolver: Resolver,
34
+ qualify: Callable[[Module, ast.expr], str | None],
35
+ *,
36
+ pure_calls: frozenset[str],
37
+ assume_pure_bases: bool,
38
+ ) -> None:
39
+ """Create an analyzer over ``modules``; ``qualify`` names expressions per module."""
40
+ self.modules = modules
41
+ self.resolver = resolver
42
+ self.qualify = qualify
43
+ self.pure_calls = pure_calls
44
+ self.assume_pure_bases = assume_pure_bases
45
+ self._memo: dict[StmtKey, Verdict] = {}
46
+
47
+ def classify_all(self) -> None:
48
+ """Set ``impure`` on every class statement whose bases make defining it observable."""
49
+ for mod in self.modules.values():
50
+ for st in mod.statements:
51
+ if st.kind == "class" and not st.impure:
52
+ st.impure, _ = self.verdict((mod.name, st.index), frozenset())
53
+
54
+ def verdict(self, key: StmtKey, active: frozenset[StmtKey]) -> Verdict:
55
+ """Impurity reason and registering flag for the class statement ``key``."""
56
+ if key in self._memo:
57
+ return self._memo[key]
58
+ if key in active:
59
+ return ("cyclic class hierarchy", True)
60
+ mod = self.modules[key[0]]
61
+ node = mod.statements[key[1]].nodes[0]
62
+ if not isinstance(node, ast.ClassDef): # pragma: no cover - callers check kind
63
+ return (None, False)
64
+ registering = _declares_registration(node)
65
+ impure: str | None = None
66
+ for base in node.bases:
67
+ reason = self._base_reason(mod, base, active | {key})
68
+ if reason:
69
+ impure = impure or reason
70
+ registering = True
71
+ self._memo[key] = (impure, registering)
72
+ return self._memo[key]
73
+
74
+ def _base_reason(self, mod: Module, base: ast.expr, active: frozenset[StmtKey]) -> str | None:
75
+ text = ast.unparse(base)
76
+ expr = base.value if isinstance(base, ast.Subscript) else base
77
+ chain = attribute_chain(expr)
78
+ if chain is None:
79
+ rules = EffectRules(lambda e: self.qualify(mod, e), self.pure_calls)
80
+ return None if rules.pure_expr(base) else f"base {text!r} may have side effects"
81
+ qualified = self.qualify(mod, expr)
82
+ if qualified is not None and self._is_external(qualified):
83
+ return self._external_base_reason(qualified, text)
84
+ res = self.resolver.resolve(mod.name, NameTarget(chain[0]), chain[1])
85
+ if not res.terminal:
86
+ return None if self.assume_pure_bases else f"base {text!r} cannot be resolved"
87
+ for tkey in sorted(res.terminal):
88
+ tst = self.modules[tkey[0]].statements[tkey[1]]
89
+ if tst.kind == "class" and isinstance(tst.nodes[0], ast.ClassDef):
90
+ if self.verdict(tkey, active)[1]:
91
+ return f"base {text!r} registers subclasses (__init_subclass__/metaclass)"
92
+ elif not self.assume_pure_bases:
93
+ return f"base {text!r} is not a plain class definition"
94
+ return None
95
+
96
+ def _external_base_reason(self, qualified: str, text: str) -> str | None:
97
+ known_pure = (
98
+ qualified.startswith("builtins.")
99
+ or qualified in PURE_BASES
100
+ or qualified.rpartition(".")[0] in PURE_BASE_MODULES
101
+ )
102
+ if known_pure or self.assume_pure_bases:
103
+ return None
104
+ return f"base {text!r} comes from outside the bundle and may register subclasses"
105
+
106
+ def _is_external(self, qualified: str) -> bool:
107
+ parts = qualified.split(".")
108
+ return not any(".".join(parts[:i]) in self.modules for i in range(len(parts), 0, -1))
109
+
110
+
111
+ def _declares_registration(node: ast.ClassDef) -> bool:
112
+ return any(k.arg == "metaclass" for k in node.keywords) or any(
113
+ isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef)) and b.name == "__init_subclass__"
114
+ for b in node.body
115
+ )
@@ -0,0 +1,168 @@
1
+ """Side-effect classification of top-level statements.
2
+
3
+ A statement is *pure* when executing it only creates bindings in its own module: it cannot print,
4
+ mutate shared state, register anything, or fail in a way that matters. Pure statements may be
5
+ removed when nothing references what they bind. Everything else is impure and always kept.
6
+
7
+ The rules are deliberately conservative. Known assumptions (documented in docs/PLAN.md):
8
+ operators, attribute reads and subscripts on module-level values do not call user code with side
9
+ effects, and the listed pure callables do not call back into user code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ from typing import TYPE_CHECKING, cast
16
+
17
+ from shakeout.analysis.registry import PURE_CALLABLES, PURE_IF_CHILDREN_PURE
18
+ from shakeout.model import ImportTarget, ModuleTarget
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+
24
+ class EffectRules:
25
+ """Purity rules for one module. ``qualify`` maps an expression to a dotted external name."""
26
+
27
+ def __init__(
28
+ self,
29
+ qualify: Callable[[ast.expr], str | None],
30
+ extra_pure: frozenset[str] = frozenset(),
31
+ ) -> None:
32
+ self.qualify = qualify
33
+ self.pure_callables = PURE_CALLABLES | extra_pure
34
+
35
+ # -- expressions -------------------------------------------------------------------------
36
+ def pure_callable(self, func: ast.expr) -> bool:
37
+ """Whether calling ``func`` is known to have no side effects."""
38
+ q = self.qualify(func)
39
+ return q is not None and q in self.pure_callables
40
+
41
+ def pure_expr(self, node: ast.expr | None) -> bool:
42
+ """Whether evaluating ``node`` at module level has no observable side effects."""
43
+ if node is None or isinstance(node, (ast.Constant, ast.Name, ast.Lambda)):
44
+ return True
45
+ if isinstance(node, ast.Call):
46
+ return (
47
+ self.pure_callable(node.func)
48
+ and all(self.pure_expr(a) for a in node.args)
49
+ and all(self.pure_expr(k.value) for k in node.keywords)
50
+ )
51
+ children = PURE_IF_CHILDREN_PURE.get(type(node))
52
+ # Anything else (comprehensions may call user __iter__, walrus binds, await...) is impure.
53
+ return children is not None and all(self.pure_expr(c) for c in children(node))
54
+
55
+ def pure_decorator(self, node: ast.expr) -> bool:
56
+ """Whether applying the decorator ``node`` has no side effects."""
57
+ if isinstance(node, ast.Call):
58
+ return self.pure_expr(node)
59
+ return self.pure_callable(node)
60
+
61
+ # -- statements --------------------------------------------------------------------------
62
+ def function_effect(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
63
+ """Why defining the function has side effects (decorators, defaults), or None."""
64
+ for deco in node.decorator_list:
65
+ if not self.pure_decorator(deco):
66
+ return f"decorator {ast.unparse(deco)!r} may have side effects"
67
+ for default in (*node.args.defaults, *node.args.kw_defaults):
68
+ if not self.pure_expr(default):
69
+ return "default argument value may have side effects"
70
+ return None
71
+
72
+ def class_shell_effect(self, node: ast.ClassDef) -> str | None:
73
+ """Effects from decorators, keywords and body; bases are checked by the linker."""
74
+ for deco in node.decorator_list:
75
+ if not self.pure_decorator(deco):
76
+ return f"class decorator {ast.unparse(deco)!r} may have side effects"
77
+ for kw in node.keywords:
78
+ return f"class keyword {kw.arg or '**'!s}= is passed to the metaclass/__init_subclass__"
79
+ for base in node.bases:
80
+ if isinstance(base, ast.Starred):
81
+ return "starred class bases"
82
+ for stmt in node.body:
83
+ reason = self.class_body_effect(stmt)
84
+ if reason:
85
+ return reason
86
+ return None
87
+
88
+ def class_body_effect(self, stmt: ast.stmt) -> str | None:
89
+ """Why executing ``stmt`` inside a class body has side effects, or None."""
90
+ match stmt:
91
+ case ast.FunctionDef() | ast.AsyncFunctionDef():
92
+ return self.function_effect(stmt)
93
+ case ast.ClassDef():
94
+ return self.class_shell_effect(stmt) or (
95
+ None if all(self.pure_expr(b) for b in stmt.bases) else "nested class base"
96
+ )
97
+ case ast.Assign(targets=targets, value=value):
98
+ if all(isinstance(t, ast.Name) for t in targets) and self.pure_expr(value):
99
+ return None
100
+ return "class attribute initializer may have side effects"
101
+ case ast.AnnAssign(target=ast.Name(), value=value):
102
+ return (
103
+ None
104
+ if self.pure_expr(value)
105
+ else "class attribute initializer may have side effects"
106
+ )
107
+ case ast.Expr(value=ast.Constant()) | ast.Pass():
108
+ return None
109
+ case _:
110
+ return f"class body contains {type(stmt).__name__}"
111
+
112
+ def assignment_effect(self, stmt: ast.Assign | ast.AnnAssign) -> str | None:
113
+ """Why a module-level assignment has side effects, or None."""
114
+ value: ast.expr | None
115
+ if isinstance(stmt, ast.Assign):
116
+ targets: list[ast.expr] = list(stmt.targets)
117
+ value = stmt.value
118
+ else:
119
+ if stmt.value is None and isinstance(stmt.target, ast.Name):
120
+ return None
121
+ targets, value = [cast("ast.expr", stmt.target)], stmt.value
122
+ for t in targets:
123
+ for sub in ast.walk(t):
124
+ if isinstance(sub, (ast.Attribute, ast.Subscript)):
125
+ return "assignment stores into an attribute or item"
126
+ if not self.pure_expr(value):
127
+ return f"initializer {_short(value)} may have side effects"
128
+ return None
129
+
130
+
131
+ _SHORT = 40
132
+
133
+
134
+ def _short(node: ast.expr | None) -> str:
135
+ if node is None:
136
+ return "''"
137
+ text = ast.unparse(node)
138
+ return repr(text if len(text) <= _SHORT else text[: _SHORT - 3] + "...")
139
+
140
+
141
+ def is_main_guard(node: ast.stmt) -> bool:
142
+ """Whether ``node`` is ``if __name__ == '__main__':``."""
143
+ if not isinstance(node, ast.If):
144
+ return False
145
+ test = node.test
146
+ if isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq):
147
+ sides = [test.left, test.comparators[0]]
148
+ names = [s for s in sides if isinstance(s, ast.Name) and s.id == "__name__"]
149
+ consts = [s for s in sides if isinstance(s, ast.Constant) and s.value == "__main__"]
150
+ return bool(names and consts)
151
+ return False
152
+
153
+
154
+ def is_type_checking_guard(node: ast.stmt, qualify: Callable[[ast.expr], str | None]) -> bool:
155
+ """Whether ``node`` is ``if TYPE_CHECKING:`` without an ``else``."""
156
+ if not isinstance(node, ast.If) or node.orelse:
157
+ return False
158
+ q = qualify(node.test)
159
+ if q in {"typing.TYPE_CHECKING", "typing_extensions.TYPE_CHECKING"}:
160
+ return True
161
+ return isinstance(node.test, ast.Name) and node.test.id == "TYPE_CHECKING"
162
+
163
+
164
+ def qualify_target(target: ImportTarget, attrs: tuple[str, ...]) -> str:
165
+ """Dotted name of ``attrs`` reached through an import target."""
166
+ if isinstance(target, ModuleTarget):
167
+ return ".".join((target.module, *attrs))
168
+ return ".".join((target.module, target.attr, *attrs))
@@ -0,0 +1,220 @@
1
+ """Static knowledge used by the effects rules.
2
+
3
+ Known-pure callables, safe base classes, and which expressions are pure when their parts are.
4
+ Pure data built once at import, so it is kept out of mutation testing (mutants of import-time
5
+ code can never be observed).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ _TYPING = ("typing", "typing_extensions")
17
+
18
+
19
+ def _qualified(*names: str) -> frozenset[str]:
20
+ return frozenset(names)
21
+
22
+
23
+ PURE_CALLABLES = _qualified(
24
+ # builtins that construct values and never call user callbacks
25
+ *(
26
+ f"builtins.{n}"
27
+ for n in (
28
+ "object",
29
+ "int",
30
+ "float",
31
+ "complex",
32
+ "str",
33
+ "bytes",
34
+ "bytearray",
35
+ "bool",
36
+ "tuple",
37
+ "list",
38
+ "dict",
39
+ "set",
40
+ "frozenset",
41
+ "range",
42
+ "slice",
43
+ "len",
44
+ "repr",
45
+ "ord",
46
+ "chr",
47
+ "hex",
48
+ "oct",
49
+ "bin",
50
+ "abs",
51
+ "round",
52
+ "property",
53
+ "staticmethod",
54
+ "classmethod",
55
+ "isinstance",
56
+ "issubclass",
57
+ "callable",
58
+ "id",
59
+ "memoryview",
60
+ )
61
+ ),
62
+ *(
63
+ f"{m}.{n}"
64
+ for m in _TYPING
65
+ for n in (
66
+ "TypeVar",
67
+ "NewType",
68
+ "ParamSpec",
69
+ "TypeVarTuple",
70
+ "NamedTuple",
71
+ "TypedDict",
72
+ "cast",
73
+ "overload",
74
+ "final",
75
+ "runtime_checkable",
76
+ "no_type_check",
77
+ "dataclass_transform",
78
+ "override",
79
+ "TypeAliasType",
80
+ "get_type_hints",
81
+ "deprecated",
82
+ )
83
+ ),
84
+ "collections.namedtuple",
85
+ "collections.OrderedDict",
86
+ "collections.defaultdict",
87
+ "collections.deque",
88
+ "collections.Counter",
89
+ "collections.ChainMap",
90
+ "re.compile",
91
+ "dataclasses.field",
92
+ "dataclasses.dataclass",
93
+ "dataclasses.make_dataclass",
94
+ "functools.partial",
95
+ "functools.lru_cache",
96
+ "functools.cache",
97
+ "functools.wraps",
98
+ "functools.cached_property",
99
+ "functools.total_ordering",
100
+ "functools.singledispatch",
101
+ "functools.singledispatchmethod",
102
+ "functools.partialmethod",
103
+ "contextlib.contextmanager",
104
+ "contextlib.asynccontextmanager",
105
+ "abc.abstractmethod",
106
+ "abc.abstractproperty",
107
+ "abc.abstractclassmethod",
108
+ "abc.abstractstaticmethod",
109
+ "enum.auto",
110
+ "enum.unique",
111
+ "enum.verify",
112
+ "enum.member",
113
+ "enum.nonmember",
114
+ "logging.getLogger",
115
+ "operator.itemgetter",
116
+ "operator.attrgetter",
117
+ "operator.methodcaller",
118
+ "pathlib.Path",
119
+ "pathlib.PurePath",
120
+ "pathlib.PurePosixPath",
121
+ "pathlib.PureWindowsPath",
122
+ "decimal.Decimal",
123
+ "fractions.Fraction",
124
+ "datetime.timedelta",
125
+ "datetime.date",
126
+ "datetime.datetime",
127
+ "datetime.time",
128
+ "datetime.timezone",
129
+ "types.MappingProxyType",
130
+ "types.SimpleNamespace",
131
+ "struct.Struct",
132
+ "string.Template",
133
+ "threading.Lock",
134
+ "threading.RLock",
135
+ "threading.local",
136
+ "threading.Event",
137
+ "weakref.WeakValueDictionary",
138
+ "weakref.WeakKeyDictionary",
139
+ "weakref.WeakSet",
140
+ "weakref.ref",
141
+ "contextvars.ContextVar",
142
+ "uuid.UUID",
143
+ "ipaddress.ip_address",
144
+ "ipaddress.ip_network",
145
+ )
146
+
147
+ # Bases whose subclassing has no global side effects.
148
+ PURE_BASES = _qualified(
149
+ *(
150
+ f"{m}.{n}"
151
+ for m in _TYPING
152
+ for n in (
153
+ "NamedTuple",
154
+ "TypedDict",
155
+ "Protocol",
156
+ "Generic",
157
+ "Any",
158
+ )
159
+ ),
160
+ "abc.ABC",
161
+ "enum.Enum",
162
+ "enum.IntEnum",
163
+ "enum.Flag",
164
+ "enum.IntFlag",
165
+ "enum.StrEnum",
166
+ "enum.ReprEnum",
167
+ "collections.OrderedDict",
168
+ "collections.defaultdict",
169
+ "collections.UserDict",
170
+ "collections.UserList",
171
+ "collections.UserString",
172
+ "collections.namedtuple",
173
+ "threading.Thread",
174
+ "threading.local",
175
+ "json.JSONEncoder",
176
+ "json.JSONDecoder",
177
+ "argparse.Action",
178
+ "argparse.ArgumentParser",
179
+ "string.Formatter",
180
+ "io.StringIO",
181
+ "io.BytesIO",
182
+ "contextlib.AbstractContextManager",
183
+ "contextlib.ContextDecorator",
184
+ "html.parser.HTMLParser",
185
+ )
186
+ PURE_BASE_MODULES = frozenset({"collections.abc", "numbers", "typing", "typing_extensions"})
187
+
188
+
189
+ def _children(*fields: str) -> Callable[[ast.AST], list[ast.expr | None]]:
190
+ def get(node: ast.AST) -> list[ast.expr | None]:
191
+ out: list[ast.expr | None] = []
192
+ for name in fields:
193
+ value: object = getattr(node, name)
194
+ if isinstance(value, list):
195
+ out.extend(cast("list[ast.expr | None]", value))
196
+ else:
197
+ out.append(cast("ast.expr | None", value))
198
+ return out
199
+
200
+ return get
201
+
202
+
203
+ # Expressions that are pure exactly when all of their sub-expressions are pure.
204
+ PURE_IF_CHILDREN_PURE: dict[type[ast.AST], Callable[[ast.AST], list[ast.expr | None]]] = {
205
+ ast.Attribute: _children("value"),
206
+ ast.Subscript: _children("value", "slice"),
207
+ ast.Tuple: _children("elts"),
208
+ ast.List: _children("elts"),
209
+ ast.Set: _children("elts"),
210
+ ast.Starred: _children("value"),
211
+ ast.Dict: _children("keys", "values"),
212
+ ast.BinOp: _children("left", "right"),
213
+ ast.UnaryOp: _children("operand"),
214
+ ast.BoolOp: _children("values"),
215
+ ast.Compare: _children("left", "comparators"),
216
+ ast.IfExp: _children("test", "body", "orelse"),
217
+ ast.JoinedStr: _children("values"),
218
+ ast.FormattedValue: _children("value", "format_spec"),
219
+ ast.Slice: _children("lower", "upper", "step"),
220
+ }