Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a subsystem manager #19

Merged
merged 3 commits into from
Jan 13, 2024
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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
all:
.PHONY: install publish test

all: install

install:
poetry install
poetry run mono install

Expand Down
2 changes: 2 additions & 0 deletions app-frame/macrostrat/app_frame/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
from .core import Application
from .compose import compose
from .subsystems import SubsystemManager, Subsystem, SubsystemError
from .exc import ApplicationError
4 changes: 4 additions & 0 deletions app-frame/macrostrat/app_frame/exc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ApplicationError(Exception):
"""Base class for all errors that should be caught and handled by the application."""

pass
152 changes: 152 additions & 0 deletions app-frame/macrostrat/app_frame/subsystems/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from toposort import toposort_flatten
from ..core import ApplicationBase
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import Version
from typing import Optional

from macrostrat.utils.logs import get_logger

from .defs import Subsystem, SubsystemError


log = get_logger(__name__)


class SubsystemManager:
"""
Storage class for plugins. Currently, we enforce a single
phase of plugin loading, ended by a call to `finished_loading_plugins`.
Hooks can be run afterwards. This removes the risk of some parts of the
application performing actions before all plugins are initialized.
"""

_hooks_fired = []
_app: Optional[ApplicationBase] = None
_subsystem_cls: Subsystem = Subsystem

def __init__(self, subsystem_cls: Subsystem = Subsystem):
self._app = None
self.__init_store = []
self.__store = None

# Ensure that the plugin class is a subclass of Subsystem
assert issubclass(subsystem_cls, Subsystem) or subsystem_cls is Subsystem
self._subsystem_cls = subsystem_cls

def __iter__(self):
try:
yield from self.__store
except TypeError:
raise SubsystemError("Cannot list subsystems until loading is finished.")

@property
def is_ready(self):
return self.__store is not None

def _is_compatible(self, sub: Subsystem):
"""Assess package compatibility: https://packaging.pypa.io/en/latest/specifiers.html"""
if sub.app_version is None:
return True
try:
spec = SpecifierSet(sub.app_version, prereleases=True)
except InvalidSpecifier:
raise SubsystemError(
f"Subsystem '{sub.name}' specifies an invalid {self.app.name} compatibility range '{sub.app_version}'"
)
return Version() in spec

def add(self, plugin):
if not plugin.should_enable(self):
return
if not self._is_compatible(plugin):
_raise_compat_error(plugin)
return

try:
self.__init_store.append(plugin)
except AttributeError:
raise SubsystemError(
f"Cannot add subsystems after {self.app.name} is finished loading."
)
except Exception as err:
_raise_load_error(plugin, err)

def add_module(self, module):
for _, obj in module.__dict__.items():
try:
assert issubclass(obj, Subsystem)
except (TypeError, AssertionError):
continue

if obj is Subsystem:
continue

self.add(obj)

def add_all(self, *plugins):
for plugin in plugins:
self.add(plugin)

def order_plugins(self, store=None):
store = store or self.__store
for p in store:
if getattr(p, "name") is None:
raise SubsystemError(
f"{self.app.name} subsystem '{p}' must have a name attribute."
)
struct = {p.name: set(p.dependencies) for p in store}
map_ = {p.name: p for p in store}
res = toposort_flatten(struct, sort=True)
return {map_[k] for k in res}

def __load_plugin(self, plugin_class, app: ApplicationBase):
if not issubclass(plugin_class, Subsystem):
raise SubsystemError(
f"{app.name} subsystems must be a subclass of Subsystem"
)
return plugin_class(app)

def finalize(self, app: ApplicationBase):
candidate_store = self.order_plugins(self.__init_store)

self.__store = []
for plugin in candidate_store:
self.__store.append(self.__load_plugin(plugin, app))

self.__init_store = None

def get(self, name: str) -> Subsystem:
"""Get a plugin object, given its name."""
for plugin in self.__store:
if plugin.name == name:
return plugin
raise AttributeError(f"Subsystem {name} not found")

def _iter_hooks(self, hook_name):
method_name = "on_" + hook_name.replace("-", "_")
for plugin in self.__store:
method = getattr(plugin, method_name, None)
if method is None:
continue
log.info(" subsystem: " + plugin.name)
yield plugin, method

def run_hook(self, hook_name, *args, **kwargs):
self._hooks_fired.append(hook_name)
for _, method in self._iter_hooks(hook_name):
method(*args, **kwargs)


def _raise_compat_error(sub: Subsystem, app: ApplicationBase):
_error = (
f"Subsystem '{sub.name}' is incompatible with {app.name} "
f"version {app.version} (expected {sub.app_version})"
)
log.error(_error)
raise SubsystemError(_error)


def _raise_load_error(sub, err):
_error = f"Could not load subsystem '{sub.name}': {err}"
log.error(_error)
raise SubsystemError(_error)
22 changes: 22 additions & 0 deletions app-frame/macrostrat/app_frame/subsystems/defs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from ..exc import ApplicationError


class SubsystemError(ApplicationError):
pass


class Subsystem:
"""A base subsystem

app_version can be set to a specifier of valid versions of the hosting application.
"""

dependencies = []
app_version = None
name = None

def __init__(self, app):
self.app = app

def should_enable(self, mgr: "SubsystemManager"):
return True
13 changes: 12 additions & 1 deletion app-frame/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion app-frame/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ packages = [
{include = "macrostrat"},
{include = "test_app"},
]
version = "1.1.0"
version = "1.2.0"

[tool.poetry.dependencies]
"macrostrat.utils" = "^1.1.0"
python = "^3.10"
python-dotenv = "^1.0.0"
toposort = "^1.5"
rich = "^13"
typer = "^0.9.0"

Expand Down
Loading
Loading