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
7 changes: 4 additions & 3 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
make the exit code 1, instead of being skipped silently or crashing the run
[#44](https://github.com/hakancelikdev/unexport/issues/44)
- Names that don't exist when the module is imported are no longer added to `__all__`:
names deleted with `del`, defined only under `if TYPE_CHECKING:` or
`if __name__ == "__main__":`, comprehension variables and annotations without a value
(`X: int`) [#40](https://github.com/hakancelikdev/unexport/issues/40)
names deleted with `del`, defined only under `if TYPE_CHECKING:`,
`if __name__ == "__main__":` or `if False:`, comprehension variables, walrus targets
in lambdas and annotations without a value (`X: int`)
[#40](https://github.com/hakancelikdev/unexport/issues/40)
- `__all__ +=`, annotated `__all__`, `extend` with a tuple and `__all__` inside
functions are handled correctly; an `__all__` built from several statements is
reported but not rewritten, and a dynamic `__all__` (e.g. `["a"] + sub.__all__`) is
Expand Down
17 changes: 12 additions & 5 deletions src/unexport/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def visit_Name(self, node: ast.Name) -> None:
self.variables.add(node.id)


def _position(node: ast.AST) -> tuple[int, int]:
return getattr(node, "lineno", 0), getattr(node, "col_offset", 0)


@dataclass
class _ModuleBindings:
"""Names bound at module level, of any kind: imports, classes, functions and variables."""
Expand All @@ -45,8 +49,9 @@ class _ModuleBindings:
not_public: set[str] = field(default_factory=set) # marked with ``# unexport: not-public``
deleted: set[str] = field(default_factory=set) # removed with ``del`` after their last binding
has_star_import: bool = False
_last_bound: dict[str, int] = field(default_factory=dict, repr=False)
_last_deleted: dict[str, int] = field(default_factory=dict, repr=False)
# (line, column) of the last binding / ``del`` of each name, so ``X = 1; del X`` on one line is ordered too.
_last_bound: dict[str, tuple[int, int]] = field(default_factory=dict, repr=False)
_last_deleted: dict[str, tuple[int, int]] = field(default_factory=dict, repr=False)

def collect(self, tree: ast.Module) -> None:
nodes: list[ast.AST] = list(tree.body)
Expand All @@ -55,7 +60,7 @@ def collect(self, tree: ast.Module) -> None:
if is_runtime_missing(node): # if TYPE_CHECKING: / if __name__ == "__main__":
continue
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Del):
self._last_deleted[node.id] = max(node.lineno, self._last_deleted.get(node.id, 0))
self._last_deleted[node.id] = max(_position(node), self._last_deleted.get(node.id, (0, 0)))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
self._bind(node.name, node)
nodes.extend(node.decorator_list) # the body is a nested scope
Expand All @@ -79,12 +84,14 @@ def collect(self, tree: ast.Module) -> None:
nodes.extend(ast.iter_child_nodes(node))

# `del NAME` after the last binding: the name doesn't exist once the module is imported.
self.deleted = {name for name, line in self._last_deleted.items() if line > self._last_bound.get(name, 0)}
self.deleted = {
name for name, position in self._last_deleted.items() if position > self._last_bound.get(name, (0, 0))
}
self.names -= self.deleted

def _bind(self, name: str, node: ast.AST) -> None:
self.names.add(name)
self._last_bound[name] = max(getattr(node, "lineno", 0), self._last_bound.get(name, 0))
self._last_bound[name] = max(_position(node), self._last_bound.get(name, (0, 0)))
if getattr(node, "skip", False):
self.not_public.add(name)

Expand Down
35 changes: 25 additions & 10 deletions src/unexport/relate.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,36 @@ def _is_main_guard(test: ast.expr) -> bool:
)


def _truth_on_import(test: ast.expr) -> bool | None:
"""Static value of an ``if`` test when the module is imported, or None when it can't be known.

``TYPE_CHECKING`` and ``__name__ == "__main__"`` are false then, and
constants such as ``if False:`` / ``if 0:`` have a fixed value.
"""
if _is_type_checking(test) or _is_main_guard(test):
return False
if isinstance(test, ast.Constant):
return bool(test.value)
if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
value = _truth_on_import(test.operand)
return None if value is None else not value
return None


def is_runtime_missing(node: ast.AST) -> bool:
"""Whether node is in the body of ``if TYPE_CHECKING:`` or ``if __name__ == "__main__":``.
"""Whether node is in a branch that doesn't run when the module is imported.

Names defined there don't exist when the module is imported, so
exporting them breaks ``from module import *``. The ``else`` branch
does run.
That is the body of ``if TYPE_CHECKING:``, ``if __name__ == "__main__":``
or ``if False:``, or the ``else`` of ``if not TYPE_CHECKING:`` / ``if True:``.
Names defined there don't exist at import time, so exporting them breaks
``from module import *``.
"""
child = node
for parent in get_parents(node):
if (
isinstance(parent, ast.If)
and child in parent.body
and (_is_type_checking(parent.test) or _is_main_guard(parent.test))
):
return True
if isinstance(parent, ast.If):
truth = _truth_on_import(parent.test)
if (truth is False and child in parent.body) or (truth is True and child in parent.orelse):
return True
child = parent
return False

Expand Down
3 changes: 2 additions & 1 deletion src/unexport/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ def _rule_node_add(node) -> bool:
)
)
def _rule_parent_not_def(node) -> bool:
return not first_occurrence(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
# A walrus in a lambda binds a local of the lambda, not a module name.
return not first_occurrence(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda))


@Rule.register( # type: ignore
Expand Down
33 changes: 33 additions & 0 deletions tests/test_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,39 @@ def test_bare_annotation(self):
"""
self.assertListEqual(self.expected_all(source), ["Assigned", "Later"])

def test_deleted_on_the_same_line(self):
source = """\
TEMP = 1; del TEMP
KEPT = 1; del KEPT; KEPT = 2
"""
self.assertListEqual(self.expected_all(source), ["KEPT"])

def test_constant_false_branches(self):
source = """\
from typing import TYPE_CHECKING

if False:
class Never: ...
if 0:
Zero = 1
if True:
Always = 1
else:
Otherwise = 1
if not TYPE_CHECKING:
Runtime = 1
else:
Checking = 1
"""
self.assertListEqual(self.expected_all(source), ["Always", "Runtime"])

def test_walrus_in_lambda(self):
source = """\
handler = lambda: (Value := 1)
Real = 1
"""
self.assertListEqual(self.expected_all(source), ["Real"])

def test_rebound_after_del(self):
source = """\
VALUE = 1
Expand Down
Loading