Skip to content

Commit

Permalink
Delete toposort dependency (#1507)
Browse files Browse the repository at this point in the history
  • Loading branch information
Akuli authored Jun 2, 2024
1 parent 6af57a7 commit 9be5765
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 31 deletions.
90 changes: 63 additions & 27 deletions porcupine/pluginloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@
import random
import time
import traceback
from collections.abc import Iterable, Sequence
from typing import Any, cast

import toposort
from collections.abc import Iterator, Sequence
from typing import Any, Callable, TypeVar, cast

from porcupine import get_main_window
from porcupine.plugins import __path__ as plugin_paths
Expand Down Expand Up @@ -276,35 +274,73 @@ def run_setup_argument_parser_functions(parser: argparse.ArgumentParser) -> None
_run_setup_argument_parser_function(info, parser)


def _handle_circular_dependency(cycle: Sequence[PluginInfo]) -> None:
error_message = " -> ".join(info.name for info in cycle)
log.error(f"circular dependency: {error_message}")
for info in cycle:
info.status = Status.CIRCULAR_DEPENDENCY_ERROR
info.error = f"Circular dependency error: {error_message}"


_T = TypeVar("_T")


# This is generic to make it easier to test. Tests use ints instead of plugin infos.
def _decide_loading_order(
dependencies: dict[_T, set[_T]], cycle_handler: Callable[[Sequence[_T]], None]
) -> Iterator[set[_T]]:
dependencies = {item: deps.copy() for item, deps in dependencies.items()}

# Create a set of all plugins.
remaining = set(dependencies.keys())
for deps in dependencies.values():
remaining.update(deps)

while remaining:
# Find plugins with no dependencies. We can set them up now.
satisfied = {item for item in remaining if not dependencies.get(item)}

if satisfied:
# We have found plugins that can be set up now. Their dependencies are
# ready, and we can set them up in any order relative to each other.
yield satisfied
forget_about = satisfied
else:
# All remaining plugins have at least one dependency.
# This means that we must have cycles. Let's find one such cycle.
cycle = [next(iter(remaining))]
while cycle.count(cycle[-1]) == 1:
cycle.append(next(iter(dependencies[cycle[-1]])))

# Throw away the non-cyclic start.
# For example, 1->2->3->4->5->3 becomes 3->4->5->3.
del cycle[: cycle.index(cycle[-1])]

cycle_handler(cycle)
forget_about = set(cycle)

remaining.difference_update(forget_about)
for deps in dependencies.values():
deps.difference_update(forget_about)


# undocumented on purpose, don't use in plugins
def run_setup_functions(shuffle: bool) -> None:
imported_infos = [info for info in plugin_infos if info.status == Status.LOADING]

# the toposort will partially work even if there's a circular
# dependency, the CircularDependencyError is raised after doing
# everything possible (see source code)
loading_order = []
try:
toposort_result: Iterable[Iterable[PluginInfo]] = toposort.toposort(_dependencies)
for infos in toposort_result:
load_list = [info for info in infos if info.status == Status.LOADING]
if shuffle:
# for plugin developers wanting to make sure that the
# dependencies specified in setup_before and setup_after
# are correct
random.shuffle(load_list)
else:
# for consistency in UI (e.g. always same order of menu items)
load_list.sort(key=(lambda info: info.name))
loading_order.extend(load_list)

except toposort.CircularDependencyError as e:
log.exception("circular dependency")

for info in set(imported_infos) - set(loading_order):
info.status = Status.CIRCULAR_DEPENDENCY_ERROR
parts = ", ".join(f"{a} depends on {b}" for a, b in e.data.items())
info.error = f"Circular dependency error: {parts}"
for infos in _decide_loading_order(_dependencies, _handle_circular_dependency):
load_list = [info for info in infos if info.status == Status.LOADING]
if shuffle:
# for plugin developers wanting to make sure that the
# dependencies specified in setup_before and setup_after
# are correct
random.shuffle(load_list)
else:
# for consistency in UI (e.g. always same order of menu items)
load_list.sort(key=(lambda info: info.name))
loading_order.extend(load_list)

for info in loading_order:
assert info.status == Status.LOADING
Expand Down
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dynamic = [] # Do not attempt to import porcupine before dependencies are insta
dependencies = [
"platformdirs>=3.0.0,<4.0.0",
"Pygments==2.15.0",
"toposort>=1.5",
"colorama>=0.2.5",
"sansio-lsp-client>=0.10.0,<0.11.0",
"python-language-server[rope,pyflakes]>=0.36.2,<1.0.0",
Expand Down Expand Up @@ -62,7 +61,6 @@ dev = [
"types-Send2Trash==1.8.2.4",
"types-setuptools==67.6.0.5",
"types-colorama==0.4.15.8",
"types-toposort==1.10.0.0",
"types-psutil==5.9.5.10",
"types-PyYAML==6.0.12.8",
"types-tree-sitter==0.20.1.2",
Expand Down
2 changes: 0 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Auto-generated in GitHub Actions. See autofix.yml.
platformdirs>=3.0.0,<4.0.0
Pygments==2.15.0
toposort>=1.5
colorama>=0.2.5
sansio-lsp-client>=0.10.0,<0.11.0
python-language-server[rope,pyflakes]>=0.36.2,<1.0.0
Expand All @@ -28,7 +27,6 @@ types-docutils==0.19.1.6
types-Send2Trash==1.8.2.4
types-setuptools==67.6.0.5
types-colorama==0.4.15.8
types-toposort==1.10.0.0
types-psutil==5.9.5.10
types-PyYAML==6.0.12.8
types-tree-sitter==0.20.1.2
Expand Down
33 changes: 33 additions & 0 deletions tests/test_pluginloader.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from porcupine import pluginloader


Expand All @@ -16,6 +18,37 @@ def test_filetypes_plugin_cant_be_loaded_while_running():
assert not pluginloader.can_setup_while_running(info)


# Tests cases ("deps" parameter) were generated by AI.
@pytest.mark.parametrize(
"deps, result",
[
({0: set(), 1: {0}, 2: {0}, 3: {1, 2}}, [{0}, {1, 2}, {3}]),
({0: set(), 1: {0}, 2: {0, 1}, 3: {2, 1}}, [{0}, {1}, {2}, {3}]),
({0: set(), 1: set(), 2: set(), 3: set(), 4: {0, 1}, 5: {2, 3}}, [{0, 1, 2, 3}, {4, 5}]),
({0: set()}, [{0}]),
],
)
def test_determining_setup_order_from_dependencies(mocker, deps, result):
m = mocker.Mock()
assert list(pluginloader._decide_loading_order(deps, m)) == result
assert m.call_count == 0


def test_simple_dependency_cycle(mocker):
m = mocker.Mock()
assert list(pluginloader._decide_loading_order({0: {0}}, m)) == []
m.assert_called_once_with([0, 0]) # cycle: 0 -> 0


def test_complex_dependency_cycle(mocker):
m = mocker.Mock()
order = list(
pluginloader._decide_loading_order({0: {1}, 1: {2}, 2: {3}, 3: {1}, 4: {3}, 5: set()}, m)
)
assert order == [{5}, {0, 4}]
m.assert_called_once_with([1, 2, 3, 1]) # cycle: 1 -> 2 -> 3 -> 1


def test_setup_order_bugs(monkeypatch):
[autoindent] = [i for i in pluginloader.plugin_infos if i.name == "autoindent"]
[rstrip] = [i for i in pluginloader.plugin_infos if i.name == "rstrip"]
Expand Down

0 comments on commit 9be5765

Please sign in to comment.