Skip to content

Commit

Permalink
Move graph change tracking to _modulegraph
Browse files Browse the repository at this point in the history
This removes a separate graph proxy object from _recipes,
which itself simplifies code while also ensuring that
the overriden  methods (former proxy methods) have the
same signature as those in modulegraph2.ModuleGraph
  • Loading branch information
ronaldoussoren committed Jul 8, 2024
1 parent 5812675 commit 5eaec01
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 84 deletions.
93 changes: 93 additions & 0 deletions src/py2app/_modulegraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
functionality useful for py2app.
"""

import contextlib
import importlib.resources
import io
import os
import typing

import modulegraph2
Expand All @@ -20,6 +22,7 @@
NamespacePackage,
Package,
PyPIDistribution,
Script,
)

from ._config import Resource
Expand Down Expand Up @@ -47,6 +50,13 @@ def load_bootstrap(bootstrap: typing.Union[str, io.StringIO]) -> str:
)


class _ChangeTracker:
__slots__ = ("updated",)

def __init__(self) -> None:
self.updated = False


class ModuleGraph(modulegraph2.ModuleGraph):
"""
Subclass of *modulegraph2.ModuleGraph* that adds some
Expand All @@ -57,6 +67,89 @@ class ModuleGraph(modulegraph2.ModuleGraph):
# Note: All "add_" methods should ensure that they are idempotent
# when adding the same value more than once because resources
# can be run multiple times while building the graph.
def __init__(
self, *, use_stdlib_implies: bool = True, use_builtin_hooks: bool = True
):
super().__init__(
use_stdlib_implies=use_stdlib_implies, use_builtin_hooks=use_builtin_hooks
)
self.__tracked_changes: typing.List[_ChangeTracker] = []

@contextlib.contextmanager
def tracked_changes(self) -> typing.Iterator[_ChangeTracker]:
"""
Contextmanager for detecting if the graph was updated by adding
nodes or edges to the graph.
"""
# XXX: This currently assumes code uses the modulegraph2 API and
# does not add nodes or edges through the lower-level
# objectgraph API.
tracker = _ChangeTracker()
self.__tracked_changes.append(tracker)
try:
yield tracker
finally:
self.__tracked_changes.remove(tracker)

def __set_updated(self) -> None:
"""
Set the "updated" flag to true for all active change trackers
"""
for tracker in self.__tracked_changes:
tracker.updated = True

def add_module(self, module_name: str) -> BaseNode:
node = self.find_node(module_name)
if node is not None:
assert isinstance(node, BaseNode)
return node

self.__set_updated()
return super().add_module(module_name)

def add_script(self, script_path: os.PathLike) -> Script:
node = self.find_node(str(script_path))
if node is not None:
assert isinstance(node, Script)
return node
self.__set_updated()
return super().add_script(script_path)

def import_package(self, importing_module: BaseNode, package_name: str) -> BaseNode:
# XXX: This is not good enough, will result in false positive update
# value if import_package was called earlier
node = self.find_node(package_name)
if node is not None:
assert isinstance(node, BaseNode)
if node.extension_attributes.get("py2app.full_package", False):
return node
self.__set_updated()
node = super().import_package(importing_module, package_name)
assert isinstance(node, BaseNode)
node.extension_attributes["py2app.full_package"] = True
return node

def import_module(self, importing_module: BaseNode, module_name: str) -> BaseNode:
node = self.find_node(module_name)
if node is not None:
assert isinstance(node, BaseNode)
try:
self.edge_data(importing_module, node)
except KeyError:
pass

else:
return node

self.__set_updated()
return super().import_module(importing_module, module_name)

def add_distribution(
self, distribution: typing.Union[PyPIDistribution, str]
) -> typing.Union[PyPIDistribution, str]:
# XXX: Need check if there actually is an update
self.__set_updated()
return super().add_distribution(distribution)

def set_expected_missing(self, node: MissingModule) -> None:
"""
Expand Down
91 changes: 7 additions & 84 deletions src/py2app/_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@
"""

import dataclasses
import pathlib
import typing

import packaging
import packaging.specifiers
from modulegraph2 import BaseNode, PyPIDistribution, Script

from ._config import RecipeOptions
from ._modulegraph import ModuleGraph
Expand All @@ -31,80 +29,6 @@
_RECIPE_FUNC = typing.Callable[[ModuleGraph, RecipeOptions], None]


class ModuleGraphProxy:
# XXX: Class name is suboptimal
# XXX: Typing...
#
# XXX: This functionality should be part of ObjectGraph,
# e.g. add a 'changecount' attribute that's incremented
# by adding/removing nodes and edges.
def __init__(self, graph: ModuleGraph) -> None:
self.__graph = graph
self.__updated = False

@property
def is_updated(self) -> bool:
return self.__updated

def add_module(self, module_name: str) -> BaseNode:
node = self.__graph.find_node(module_name)
if node is not None:
assert isinstance(node, BaseNode)
return node

self.__updated = True
return self.__graph.add_module(module_name)

def add_script(self, script_path: pathlib.Path) -> Script:
node = self.__graph.find_node(str(script_path))
if node is not None:
assert isinstance(node, Script)
return node
self.__updated = True
return self.__graph.add_script(script_path)

def import_package(self, importing_module: BaseNode, package_name: str) -> BaseNode:
# XXX: This is not good enough, will result in false positive update
# value if import_package was called earlier
node = self.__graph.find_node(package_name)
if node is not None:
assert isinstance(node, BaseNode)
if node.extension_attributes.get("py2app.full_package", False):
return node
self.__updated = True
node = self.__graph.import_package(importing_module, package_name)
assert isinstance(node, BaseNode)
node.extension_attributes["py2app.full_package"] = True
return node

def import_module(self, importing_module: BaseNode, module_name: str) -> BaseNode:
node = self.__graph.find_node(module_name)
if node is not None:
assert isinstance(node, BaseNode)
try:
self.__graph.edge_data(importing_module, node)
except KeyError:
pass

else:
return node

self.__updated = True
return self.__graph.import_module(importing_module, module_name)

def add_distribution(
self, distribution: typing.Union[PyPIDistribution, str]
) -> typing.Union[PyPIDistribution, str]:
# XXX: Need check if there actually is an update
self.__updated = True
return self.__graph.add_distribution(distribution)

def __getattr__(self, name: str) -> typing.Any:
if name.startswith("_"):
raise AttributeError(name)
return getattr(self.__graph, name)


@dataclasses.dataclass
class RecipeInfo:
# XXX: Should there be a name here?
Expand Down Expand Up @@ -199,16 +123,15 @@ def process_recipes(

steps = 0
while True:
proxy = typing.cast(ModuleGraph, ModuleGraphProxy(graph))

for recipe in iter_recipes(graph):
progress.update(task_id, current=recipe.name)
progress.step_task(task_id)
steps += 1
with graph.tracked_changes() as tracker:
for recipe in iter_recipes(graph):
progress.update(task_id, current=recipe.name)
progress.step_task(task_id)
steps += 1

recipe.callback(proxy, options)
recipe.callback(graph, options)

if typing.cast(ModuleGraphProxy, proxy).is_updated:
if tracker.updated:
progress.info(f"Recipe {recipe.name!r} updated the dependency graph")
else:
break
Expand Down

0 comments on commit 5eaec01

Please sign in to comment.