Feature: Extension Framework upgrade incl. middleware, extensionsdependencies and -callbacks (#2186)

This PR brings the ability of having your own callbacks for extensions, depending on other extensions and callbacks now have a return_style either collect (default) or middleware
This commit is contained in:
k9ert 2023-02-11 11:21:31 +01:00 committed by GitHub
parent a1bb412451
commit 816726b935
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 517 additions and 173 deletions

View file

@ -9,3 +9,4 @@ Some important one is the `after_serverpy_init_app` which passes a `Scheduler` c
In the case of middleware, you can pass one object which will in turn passed to all extensions which registered that callback. Have a look at the `adjust_view_model` callback which is explained in detail in the frontend-section.

View file

@ -148,8 +148,6 @@ A reasonable `mywalletdetails.jinja` would look like this:
## Extending certain pages or complete endpoints (don't use this for now)
Unfortunately this method is only able to be used once per Extension. So it's more or less unusable right now as there are already two extensions which are using those.
For some endpoints, there is the possibility to extend/change parts of a page or the complete page. This works by declaring the `callback_adjust_view_model` method in your extension and modify the ViewModel which got passed into the callback. As there is only one callback for all types of ViewModels, you will need to check for the type that you're expecting and only adjust this type. Here is an example:
```python
@ -163,10 +161,9 @@ class ExtensionidService(Service):
# view_model.about_redirect=url_for("spectrum_endpoint.some_enpoint_here")
# but we do it small here and only replace a specific component:
view_model.get_started_include = "spectrum/welcome/components/get_started.jinja"
return view_model
return None
return view_model
```
Make sure to return `None` if the `view_model` is not the type you're interested in.
Make sure to return the view_model in anycase. No matter whether it's the correct type or not.
In this example, a certain part of the page gets replaced. As you can read in the comments, you could also trigger a complete redirect to a different endpoint.

View file

@ -1,20 +1,20 @@
import logging
from cryptoadvance.specter.managers.service_manager import ServiceManager
from cryptoadvance.specter.managers.service_manager import ExtensionManager
logger = logging.getLogger(__name__)
# Collecting template and static files from the different services in src/cryptoadvance/specter/services
service_template_datas = [
(service_dir, "templates")
for service_dir in ServiceManager.get_service_x_dirs("templates")
for service_dir in ExtensionManager.get_service_x_dirs("templates")
]
service_static_datas = [
(service_dir, "static")
for service_dir in ServiceManager.get_service_x_dirs("static")
for service_dir in ExtensionManager.get_service_x_dirs("static")
]
# Collect Packages from the services, including service- and controller-classes
service_packages = ServiceManager.get_service_packages()
service_packages = ExtensionManager.get_service_packages()
datas = [*service_template_datas, *service_static_datas]

View file

@ -686,9 +686,9 @@ specterext-exfund==0.1.7 \
specterext-faucet==0.1.2 \
--hash=sha256:86db78a6c41688152cfeec14efafd6d06c97e6edd9735461ee897495f90cb2e8
# via -r requirements.in
specterext-stacktrack==0.2.1 \
--hash=sha256:28729d1a981d8c061902b3d26baefc723d286aecea4d203ed729d1c89a9de267 \
--hash=sha256:34a5e9da8a3cb7a4c8b7bc4e0a417a043150e608a7261e56d84fbfba9af15b8a
specterext-stacktrack==0.3.0 \
--hash=sha256:14f96f1f552f57ba017b8bc642f07343edbb1abafe09e03bbaae179d78d7ce23 \
--hash=sha256:9e2946185730aab377951e83a27d8791a34e0f031e44f15991212b6b85722ca0
# via -r requirements.in
sqlalchemy==1.4.42 \
--hash=sha256:04f2598c70ea4a29b12d429a80fad3a5202d56dce19dd4916cc46a965a5ca2e9 \

View file

@ -11,7 +11,7 @@ from ..helpers import alias, calc_fullpath, load_jsons
from ..node import Node, NonExistingNode
from ..internal_node import InternalNode
from ..services import callbacks
from ..managers.service_manager import ServiceManager
from ..managers.service_manager import ExtensionManager
from ..util.bitcoind_setup_tasks import setup_bitcoind_thread
logger = logging.getLogger(__name__)
@ -36,7 +36,7 @@ class NodeManager:
self.only_tor = only_tor
self.bitcoind_path = bitcoind_path
self.internal_bitcoind_version = internal_bitcoind_version
self.service_manager: ServiceManager = service_manager
self.service_manager: ExtensionManager = service_manager
self.load_from_disk(data_folder)
internal_nodes = [
node for node in self.nodes.values() if not node.external_node

View file

@ -1 +1,5 @@
from .service_manager import *
# intermediate state!
# next steps:
# from .extension_manager import ExtensionManager
# and then refactor the whole package to the new name ...
from .service_manager import ExtensionManager

View file

@ -0,0 +1,150 @@
import logging
from typing import Dict, List
from cryptoadvance.specter.util.reflection import (
get_template_static_folder,
get_subclasses,
)
from flask import current_app as app
from flask import url_for
from flask.blueprints import Blueprint
from cryptoadvance.specter.util.specter_migrator import SpecterMigration
from ...services.service import Service
from ...services.callbacks import *
from ...services.callbacks import Callback
from ...specter_error import SpecterInternalException
logger = logging.getLogger(__name__)
class CallbackExecutor:
"""encapsulating the complexities of the extension callbacks"""
def __init__(self, extensions):
self._extensions = extensions
def execute_ext_callbacks(self, callback, *args, **kwargs):
"""will execute the callback function for each extension which has defined that method
the callback_id needs to be passed and specify why the callback has been called.
It needs to be one of the constants defined in cryptoadvance.specter.services.callbacks
"""
self.check_callback(callback)
# No debug statement here possible as this is called for every request and would flood the logs
# logger.debug(f"Executing callback {callback_id}")
return_values = {}
for ext in self.services_sorted:
if hasattr(ext, f"callback_{callback.id}"):
# logger.debug(f"About to execute on ext {ext.id} callback_{callback.id}")
return_values[ext.id] = getattr(ext, f"callback_{callback.id}")(
*args, **kwargs
)
# logger.debug(f"returned {return_values[ext.id]}")
if callback.return_style == "middleware":
args = [return_values[ext.id]]
# Filtering out all None return values
return_values = {k: v for k, v in return_values.items() if v is not None}
# logger.debug(f"return_values for callback {callback.id} {return_values}")
if callback.return_style == "collect":
return return_values
elif callback.return_style == "middleware":
return args[0]
else:
raise SpecterInternalException(
f"""
Unknown callback return_style {callback.return_style} for callback {callback}
"""
)
@property
def extensions(self) -> Dict[str, Service]:
return self._extensions or {}
@property
def services_sorted(self) -> List[Service]:
"""A list of sorted extensions. First sort-criteria is the dependency. Second one the sort-priority"""
if hasattr(self, "_services_sorted"):
return self._services_sorted
exts_sorted = topological_sort(self.extensions.values())
for ext in exts_sorted:
ext.__class__.dependency_level = 0
for ext in exts_sorted:
set_dependency_level_recursive(ext.__class__, 0)
exts_sorted.sort(
key=lambda x: (-x.dependency_level, -getattr(x, "sort_priority", 0))
)
self._services_sorted = exts_sorted
return self._services_sorted
@property
def all_callbacks(self):
return get_subclasses(Callback)
def check_callback(self, callback, *args, **kwargs):
"""A callback argument needs to:
* be a class which derives from callback
* or a instance of such a class. This might simplify the the whole extensionframework in the future
* If a callback has the return-type "middleware", it has to have exactly one argument (otherwise the whole thing gets to complicated)
If one of the checks fails, it'll raise an SpecterInternalException. This behaviour should be improved over time but as long as
the number of extensions are so small, that behaviour helps to catch issues in all extensions.
ToDo in the future: Complaining and ignoring.
"""
if type(callback) != type:
callback: Callback = callback.__class__
if callback not in self.all_callbacks:
raise SpecterInternalException(
f"""
Non existing callback_id: {callback} or your class does not inherit from Callback
"""
)
if callback.return_style == "middleware":
if len(args) > 1:
raise SpecterInternalException(
f"""
The callback {callback} is using middleware but it's passing more than one argument: {args}."
"""
)
if len(kwargs.values()) > 0:
raise SpecterInternalException(
f"""
The callback {callback} is using middleware but it's using named arguments: {kwargs}.
"""
)
def topological_sort(instances):
"""Sorts a list of instances so that non dependent ones come first"""
class_map = {instance.__class__: instance for instance in instances}
in_degree = {cls: 0 for cls in class_map.keys()}
graph = {cls: set() for cls in class_map.keys()}
for cls, instance in class_map.items():
for dep in getattr(cls, "depends", []):
graph[dep].add(cls)
in_degree[cls] += 1
no_incoming_edges = [cls for cls in class_map.keys() if in_degree[cls] == 0]
output = []
while no_incoming_edges:
node = no_incoming_edges.pop()
output.append(class_map[node])
for child in graph[node]:
in_degree[child] -= 1
if in_degree[child] == 0:
no_incoming_edges.append(child)
if len(output) != len(class_map):
raise ValueError("Graph contains a cycle.")
return output
def set_dependency_level_recursive(ext, level=0):
for dep in getattr(ext, "depends", []):
dep.dependency_level = max(getattr(dep, "dependency_level", 0), level + 1)
set_dependency_level_recursive(dep, level + 1)

View file

@ -10,7 +10,10 @@ from typing import Dict, List
from cryptoadvance.specter.config import ProductionConfig
from cryptoadvance.specter.device import Device
from cryptoadvance.specter.specter_error import SpecterError, SpecterInternalException
from cryptoadvance.specter.managers.service_manager.callback_executor import (
CallbackExecutor,
)
from cryptoadvance.specter.specter_error import SpecterError
from cryptoadvance.specter.user import User
from cryptoadvance.specter.util.reflection import get_template_static_folder
from flask import current_app as app
@ -33,7 +36,7 @@ from ...util.reflection_fs import search_dirs_in_path
logger = logging.getLogger(__name__)
class ServiceManager:
class ExtensionManager:
"""Loads support for all Services it auto-discovers."""
def __init__(self, specter, devstatus_threshold):
@ -75,14 +78,15 @@ class ServiceManager:
self.specter.ext[clazz.id] = self._services[clazz.id]
# maybe register the blueprint
self.register_blueprint_for_ext(clazz, self._services[clazz.id])
self.add_devices_for_ext(clazz, self._services[clazz.id])
self.register_devices_from_ext(self._services[clazz.id])
logger.info(f"Service {clazz.__name__} activated ({clazz.devstatus})")
else:
logger.info(
f"Service {clazz.__name__} not activated due to devstatus ( {self.devstatus_threshold} > {clazz.devstatus} )"
)
logger.info("----> finished service loading")
self.execute_ext_callbacks("afterServiceManagerInit")
logger.info("----> finished service processing")
self.callback_executor = CallbackExecutor(self.services)
self.execute_ext_callbacks(callbacks.afterExtensionManagerInit)
@classmethod
def register_blueprint_for_ext(cls, clazz, ext):
@ -187,13 +191,49 @@ class ServiceManager:
)
@classmethod
def add_devices_for_ext(cls, clazz, ext):
if hasattr(clazz, "devices"):
devices_modules = clazz.devices
else:
def register_devices_from_ext(cls, ext):
"""extract the devices from the extension and appends all found one to
cryptoadvance.specter.devices
"""
classes = cls.extract_thing_classes_from_extension("devices", Device, ext)
if not classes:
return
from cryptoadvance.specter.devices import __all__ as all_devices
for device_class in classes:
all_devices.append(device_class)
logger.debug(f" Loaded Device {device_class}")
@classmethod
def register_callbacks_from_ext(cls, ext):
"""extract all callbacks from the extension and import them so that they are
discoverable as subclass of Callback.
"""
# importing it is the main job here. If it's imported, it'll also be discovered
# as a subclass of Callback.
classes = cls.extract_thing_classes_from_extension(
"callbacks", callbacks.Callback, ext
)
for callback_class in classes:
logger.debug(f" Loaded Callback {callback_class}")
@classmethod
def extract_thing_classes_from_extension(
cls, things: str, thing_class: type, ext
) -> List[type]:
"""If an extension has a definition like that:
class SomeExtension(Extension)
things = ["someNym.specterext.some_extensions.things"]
then this method will return a list of all the thing_class classes
which can be found in that module:
extract_thing_classes_from_extension("things",Thing, someExtension)
"""
if hasattr(ext.__class__, things):
thing_modules: List[str] = getattr(ext.__class__, things)
else:
return []
classes = []
for module in devices_modules:
for module in thing_modules:
try:
classes.extend(
get_classlist_of_type_clazz_from_modulelist(Device, [module])
@ -278,33 +318,9 @@ class ServiceManager:
the callback_id needs to be passed and specify why the callback has been called.
It needs to be one of the constants defined in cryptoadvance.specter.services.callbacks
"""
if callback_id not in dir(callbacks):
raise Exception(f"Non existing callback_id: {callback_id}")
# No debug statement here possible as this is called for every request and would flood the logs
# logger.debug(f"Executing callback {callback_id}")
return_values = {}
for ext in self.services.values():
if hasattr(ext, f"callback_{callback_id}"):
try:
return_values[ext.id] = getattr(ext, f"callback_{callback_id}")(
*args, **kwargs
)
except Exception as e:
# Development should catch all errors early!
if app.config["SPECTER_CONFIGURATION_CLASS_FULLNAME"].endswith(
"DevelopmentConfig"
):
raise e
logger.error(
"Exception {e} while executing {callback_id} for extension {ext.id}"
)
logger.exception(e)
elif hasattr(ext, "callback"):
return_values[ext.id] = ext.callback(callback_id, *args, **kwargs)
# Filtering out all None return values
return_values = {k: v for k, v in return_values.items() if v is not None}
# logger.debug(f"return_values for callback {callback_id} {return_values}")
return return_values
return self.callback_executor.execute_ext_callbacks(
callback_id, *args, **kwargs
)
@property
def services(self) -> Dict[str, Service]:
@ -418,8 +434,7 @@ class ServiceManager:
]
logger.info(f"Initial arr:")
for element in arr:
logger.info(element)
# /home/kim/src/specter-desktop/.buildenv/lib/python3.8/site-packages/cryptoadvance/specter/services/bitcoinreserve/templates
logger.debug(element)
# /home/kim/src/specter-desktop/.buildenv/lib/python3.8/site-packages/cryptoadvance/specter/services/swan/templates
# filter only directories
@ -474,12 +489,13 @@ class ServiceManager:
)
)
logger.info(f"After extending: {arr}")
# Before we transform the arr into an array of strings, we iterate through all services to discover
# the devices which might be specified in there
devices_arr = []
for clazz in arr:
if hasattr(clazz, "devices"):
logger.debug("class {clazz} has devices: {clazz.devices}")
logger.debug(f"class {clazz.__name__} has devices: {clazz.devices}")
for device in clazz.devices:
try:
import_module(device)
@ -487,12 +503,24 @@ class ServiceManager:
except ModuleNotFoundError as e:
pass
# Same for callbacks
callbacks_arr = []
for clazz in arr:
if hasattr(clazz, "callbacks"):
logger.debug(f"class {clazz.__name__} has callbacks: {clazz.callbacks}")
for device in clazz.callbacks:
try:
import_module(device)
callbacks_arr.append(device)
except ModuleNotFoundError as e:
pass
# Transform into array of strings
arr = [clazz.__module__ for clazz in arr]
# Add the devices
arr.extend(devices_arr)
logger.debug(f"After transforming + devices: {arr}")
arr.extend(callbacks_arr)
logger.debug(f"After transforming + devices + callbacks: {arr}")
# Controller-Packagages from the services are not imported via the service but via the baseclass
# Therefore hiddenimport don't find them. We have to do it here.

View file

@ -5,6 +5,11 @@ from distutils.core import setup
from http.client import HTTPConnection
from pathlib import Path
from cryptoadvance.specter.liquid.rpc import LiquidRPC
from cryptoadvance.specter.managers.service_manager import ExtensionManager
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.services import callbacks
from cryptoadvance.specter.util.reflection import get_template_static_folder
from dotenv import load_dotenv
from flask import Flask, jsonify, redirect, request, session, url_for
from flask_apscheduler import APScheduler
@ -17,7 +22,7 @@ from werkzeug.wrappers import Response
from cryptoadvance.specter.hwi_rpc import HWIBridge
from cryptoadvance.specter.liquid.rpc import LiquidRPC
from cryptoadvance.specter.managers.service_manager import ServiceManager
from cryptoadvance.specter.managers.service_manager import ExtensionManager
from cryptoadvance.specter.rpc import BitcoinRPC
from cryptoadvance.specter.services import callbacks
from cryptoadvance.specter.util.reflection import get_template_static_folder
@ -158,7 +163,7 @@ def init_app(app: SpecterFlask, hwibridge=False, specter=None):
# It's an attribute to the specter but specter is not aware of it.
# However some managers are aware of it and so we need to split
# instantiation from initializing and in between attach the service_manager
specter.service_manager = ServiceManager(
specter.service_manager = ExtensionManager(
specter=specter, devstatus_threshold=app.config["SERVICES_DEVSTATUS_THRESHOLD"]
)

View file

@ -74,28 +74,11 @@ def wallets_overview():
# that's why we need so many lines for just expressing:
# "Here is a ViewModel, adjust it if you want"
# We need to change that method to enable "middleware"
wallets_overview_vm_dict = app.specter.service_manager.execute_ext_callbacks(
wallets_overview_vm = app.specter.service_manager.execute_ext_callbacks(
adjust_view_model, WalletsOverviewVm()
)
number_of_wallets_overview_vm = len(
[
wallets_overview_vm
for wallets_overview_vm in wallets_overview_vm_dict.values()
if type(wallets_overview_vm) == WalletsOverviewVm
]
)
if number_of_wallets_overview_vm > 1:
raise Exception(
f"Seems that we have more than one WalletsOverviewVm Extension: {wallets_overview_vm_dict} "
)
if number_of_wallets_overview_vm == 1:
wallets_overview_vm = list(wallets_overview_vm_dict.values())[0]
else:
wallets_overview_vm = WalletsOverviewVm()
if wallets_overview_vm.wallets_overview_redirect != None:
logger.info(
f"Extension {list(wallets_overview_vm_dict.keys())[0]} redirects to {wallets_overview_vm.wallets_overview_redirect}"
)
logger.info(f"redirecting to {wallets_overview_vm.wallets_overview_redirect}")
return redirect(wallets_overview_vm.wallets_overview_redirect)
wallet: Wallet

View file

@ -53,28 +53,11 @@ def about():
# that's why we need so many lines for just expressing:
# "Here is a ViewModel, adjust it if you want"
# We need to change that method to enable "middleware"
welcome_vm_dict = app.specter.service_manager.execute_ext_callbacks(
welcome_vm = app.specter.service_manager.execute_ext_callbacks(
adjust_view_model, WelcomeVm()
)
number_of_welcome_vm = len(
[
wallets_overview_vm
for wallets_overview_vm in welcome_vm_dict.values()
if type(wallets_overview_vm) == WelcomeVm
]
)
if number_of_welcome_vm > 1:
raise SpecterInternalException(
f"Seems that we have more than one WelcomeVm Extension: {welcome_vm_dict} "
)
if number_of_welcome_vm == 1:
welcome_vm = list(welcome_vm_dict.values())[0]
else:
welcome_vm = WelcomeVm()
if welcome_vm.about_redirect != None:
logger.info(
f"Extension {list(welcome_vm_dict.keys())[0]} redirects to {welcome_vm_dict.wallets_overview_redirect}"
)
logger.info(f"redirecting to {welcome_vm.about_redirect}")
return redirect(welcome_vm.about_redirect)
if request.method == "POST":

View file

@ -1,75 +1,119 @@
""" Here we have some constants getting an id for extension-points/callbacks. As camelcase
is used, we don't use CAPITAL letters to not loose the meaning of the camelcase.
""" Here we have classes extension-points/callbacks.
These constants are expected as parameter to the ServiceManager.callback function
These constants are expected as parameter to the ExtensionManager.callback function
and it'll throw an exception if the constant does not exist.
Callbacks have a return_style which determines how the return values get collected:
* "collect" will return a dict where the key is the id of the extension and the value is
the returnvalue of that extension
* "middleware" will expect that the extension is returning something which will in turn
get a parameter to the next extension`s call. The last extension's returnvalue will
then become the returnvalue of the callback.
There are some weak naming conventions:
1. after/before
2. class or file
3. method or function
callbacks
"""
"""
I don't know why we have this one. Doesn't seem to be used anywhere.
"""
afterServiceManagerInit = "afterServiceManagerInit"
"""
This one is called, after the init_app method has finished. The "run" method has not
class Callback:
return_style = "collect"
class afterExtensionManagerInit(Callback):
"""
I don't know why we have this one. Doesn't seem to be used anywhere.
"""
id = "afterExtensionManagerInit"
class after_serverpy_init_app(Callback):
"""
This one is called, after the init_app method has finished. The "run" method has not
been executed yet and so urls can't be called yet.
So this is the best place for almost all extensions to do their initializing work.
"""
after_serverpy_init_app = "after_serverpy_init_app"
"""
""" Extensions which want to extend the settings dialog
id = "after_serverpy_init_app"
class add_settingstabs(Callback):
"""Extensions which want to extend the settings dialog
needs to return something like: return [{"title": "token", "endpoint":"settings_token"}]
Check the extension-docs for a comprehensive example.
"""
add_settingstabs = "add_settingstabs"
"""
""" Extensions which want to extend the wallet dialog
id = "add_settingstabs"
class add_wallettabs(Callback):
"""Extensions which want to extend the wallet dialog
needs to return something like: return [{"title": "sometitle", "endpoint":"yourendpoint"}]
Check the extension-docs for a comprehensive example.
"""
add_wallettabs = "add_wallettabs"
"""
""" Endpoints might define their behaviour via a ViewModel. Those Models are passed here and
id = "add_wallettabs"
class adjust_view_model(Callback):
"""Endpoints might define their behaviour via a ViewModel. Those Models are passed here and
extensions can modify that behaviour via Modifying that model. Currently there is only:
cryptoadvance.specter.server_enpoints.welcome.welcome_vm.WelcomeVm
"""
adjust_view_model = "adjust_view_model"
"""
"""
id = "adjust_view_model"
return_style = "middleware"
class specter_persistence_callback(Callback):
"""
This one is called, whenever a file is persisted. To call external scripts in another
process, you better use the SPECTER_PERSISTENCE_CALLBACK Env Var or it's asynchronous cousin
SPECTER_PERSISTENCE_CALLBACK_ASYNC.
"""
specter_persistence_callback = "specter_persistence_callback"
"""
"""
id = "specter_persistence_callback"
class flask_before_request(Callback):
"""
Will get called before every request via the Flask's @app.before_request
"""
flask_before_request = "flask_before_request"
"""
"""
id = "flask_before_request"
class specter_added_to_flask_app(Callback):
"""
Will get called right after having access to app.specter
"""
specter_added_to_flask_app = "specter_added_to_flask_app"
"""
"""
Will get called when the server_endpoints.flash is called
"""
flash = "flash"
"""
Callback that is not used yet, but could be implmented in server_endpoints just as flash
"""
create_and_show_notification = "create_and_show_notification"
id = "specter_added_to_flask_app"
"""
class flash(Callback):
"""Will get called if anyone is calling server_endpoionts.flash"""
id = "flash"
class cleanup_on_exit(Callback):
"""
Callback that is called last in specter.cleanup_on_exit()
"""
cleanup_on_exit = "cleanup_on_exit"
"""
id = "cleanup_on_exit"
# ToDo: Think about this callback as it's specific to the notification-extension
# We have now the possibility that callbacks are added by extensions but on the other hand
# Core code should not execute code which got specified in extensions.
# Maybe we could weaken that principle, though.
class create_and_show_notification(Callback):
"""
Callback that is not used yet, but could be implmented in server_endpoints just as flash
"""
id = "create_and_show_notification"

View file

@ -21,7 +21,7 @@ services_endpoint = Blueprint(
# All blueprint from Services are no longer loaded statically but dynamically when the service-class in initialized
# check cryptoadvance.specter.services.service_manager.Service for doing that and
# check cryptoadvance.specter.services/**/manifest for instances of Service-classes and
# check cryptoadvance.specter.services.service_manager.ServiceManager.services for initialisation of ServiceClasses
# check cryptoadvance.specter.services.service_manager.ExtensionManager.services for initialisation of ServiceClasses
def user_secret_decrypted_required(func):

View file

@ -31,8 +31,8 @@ class ServiceOptionality:
opt_out = "opt_out"
class Service:
"""A base class for Services"""
class Extension:
"""A base class for Extensions"""
# These should be overrided in implementation classes
id = None
@ -42,7 +42,7 @@ class Service:
desc = None # TODO: rename to "description" to be explicit
has_blueprint = True # the default
# If the blueprint gets a "/ext" prefix (isolated_client = True), the login cookie won't work for all specter core functionality
isolated_client = True
isolated_client = False
devstatus = devstatus_alpha
optionality = ServiceOptionality.opt_in
visible_in_sidebar = True
@ -246,3 +246,11 @@ class Service:
return render_template("myext/inject_in_basejinja_body_bottom.jinja")
"""
pass
class Service(Extension):
"""Deprecated! You should derive from Extension!
This is only here for backwards compatibility and will be removed after some time
"""
pass

View file

@ -32,13 +32,18 @@ from .managers.config_manager import ConfigManager
from .managers.device_manager import DeviceManager
from .managers.node_manager import NodeManager
from .managers.otp_manager import OtpManager
from .managers.service_manager import ServiceManager
from .managers.service_manager import ExtensionManager
from .managers.user_manager import UserManager
from .managers.wallet_manager import WalletManager
from .node import Node
from .persistence import read_json_file, write_json_file, write_node
from .process_controller.bitcoind_controller import BitcoindPlainController
from .rpc import BitcoinRPC, RpcError, get_default_datadir
from .rpc import (
BitcoinRPC,
RpcError,
get_default_datadir,
)
from .managers.service_manager import ExtensionManager
from .services.service import devstatus_alpha, devstatus_beta, devstatus_prod
from .services import callbacks
from .specter_error import ExtProcTimeoutException, SpecterError

View file

@ -187,16 +187,16 @@ def get_subclasses_for_clazz(
continue
try:
module = import_module(f"{module_name}.service")
logger.info(f" Imported {module_name}.service")
logger.debug(f" Imported {module_name}.service")
except ModuleNotFoundError as e:
try:
# Another style is orgname.specterext.extensionid, for that we have to guess the orgname:
orgname = importer.path.split(os.path.sep)[-2]
logger.info(f"guessing orgname: {orgname}")
orgname = str(importer).split(os.path.sep)[-2]
logger.debug(f"guessing orgname: {orgname}")
module = import_module(
f"{orgname}.specterext.{module_name}.service"
)
logger.info(
logger.debug(
f" Imported {orgname}.specterext.{module_name}.service"
)
except ModuleNotFoundError as e:

View file

@ -160,6 +160,4 @@ class SpectrumService(Service):
view_model.get_started_include = (
"spectrum/welcome/components/get_started.jinja"
)
else:
return None
return view_model

View file

@ -5,7 +5,7 @@ from pathlib import PosixPath, Path
import os
from unittest.mock import MagicMock
from flask import Flask
from cryptoadvance.specter.managers.service_manager import ServiceManager
from cryptoadvance.specter.managers.service_manager import ExtensionManager
from cryptoadvance.specter.services.callbacks import after_serverpy_init_app
from cryptoadvance.specterext.swan.service import SwanService
@ -13,12 +13,12 @@ from cryptoadvance.specterext.swan.service import SwanClient
from cryptoadvance.specterext.devhelp.service import DevhelpService
def test_ServiceManager2(mock_specter, mock_flaskapp, caplog):
def test_ExtensionManager2(mock_specter, mock_flaskapp, caplog):
ctx = mock_flaskapp.app_context()
ctx.push()
sm = ServiceManager(mock_specter, "alpha")
sm = ExtensionManager(mock_specter, "alpha")
# We have passed the TestConfig which is (hopefully) not existing in the Swan Extension
# So the ServiceManager will move up the dependency tree of TestConfig until it finds
# So the ExtensionManager will move up the dependency tree of TestConfig until it finds
# a Config and will copy the keys into the flask-config
assert mock_flaskapp.config["SWAN_API_URL"] == "https://api.dev.swanbitcoin.com"
assert sm.services["swan"] != None
@ -28,7 +28,7 @@ def test_ServiceManager2(mock_specter, mock_flaskapp, caplog):
def test_is_class_from_loaded_extension(mock_specter, mock_flaskapp):
with mock_flaskapp.app_context():
sm = ServiceManager(mock_specter, "alpha")
sm = ExtensionManager(mock_specter, "alpha")
assert type(sm.services_sorted[0]) == SwanService
assert sm.is_class_from_loaded_extension(SwanClient)
assert sm.is_class_from_loaded_extension(SwanService)
@ -36,12 +36,12 @@ def test_is_class_from_loaded_extension(mock_specter, mock_flaskapp):
@pytest.mark.skip(reason="The .buildenv directoy does not exist on the CI-Infra")
def test_ServiceManager_get_service_x_dirs(caplog):
def test_ExtensionManager_get_service_x_dirs(caplog):
caplog.set_level(logging.DEBUG)
try:
os.chdir("./pyinstaller")
# THis is usefull in the pytinstaller/specterd.spec
dirs = ServiceManager.get_service_x_dirs("templates")
dirs = ExtensionManager.get_service_x_dirs("templates")
# As the tests are executed in a development-environment (pip3 install -e .), the results we get back here
# are not the same than the one we would get back when really are building. Because in that case,
# the .buildenv would the environment and no symlinks would link to src/cryptoadavance...
@ -56,7 +56,7 @@ def test_ServiceManager_get_service_x_dirs(caplog):
for path in dirs:
assert str(path).endswith("templates")
dirs = ServiceManager.get_service_x_dirs("static")
dirs = ExtensionManager.get_service_x_dirs("static")
assert f"{expected_folder}/cryptoadvance/specterext/swan/static" in [
str(dir) for dir in dirs
]
@ -70,7 +70,7 @@ def test_ServiceManager_get_service_x_dirs(caplog):
def test_ServiceManager_get_service_packages(caplog):
caplog.set_level(logging.DEBUG)
packages = ServiceManager.get_service_packages()
packages = ExtensionManager.get_service_packages()
assert "cryptoadvance.specterext.electrum.service" in packages
assert "cryptoadvance.specterext.electrum.devices.electrum" in packages
assert "cryptoadvance.specterext.swan.service" in packages
@ -97,7 +97,7 @@ def test_ServiceManager_make_path_relative(caplog):
),
Path("wurstbrot/something/.env/site-packages/the_rest"),
]
arr = [ServiceManager._make_path_relative(path) for path in arr]
arr = [ExtensionManager._make_path_relative(path) for path in arr]
assert arr[0] == PosixPath(
"site-packages/cryptoadvance/specter/services/swan/templates"
)
@ -120,7 +120,7 @@ def mock_flaskapp(mock_specter):
]
flaskapp_mock.config["ISOLATED_CLIENT_EXT_URL_PREFIX"] = "/spc/ext"
flaskapp_mock.config["EXT_URL_PREFIX"] = "/ext"
# The ServiceManager is a flask-aware component. It will load all the services
# The ExtensionManager is a flask-aware component. It will load all the services
# however, in order to configure them, he needs to know about the configuration
# of the specterApp.
flaskapp_mock.config[

View file

@ -0,0 +1,142 @@
import pytest
import logging
from cryptoadvance.specter.managers.service_manager.callback_executor import (
CallbackExecutor,
)
from cryptoadvance.specter.managers.service_manager.callback_executor import (
topological_sort,
set_dependency_level_recursive,
)
from cryptoadvance.specter.services.callbacks import (
flask_before_request,
adjust_view_model,
Callback,
)
from cryptoadvance.specter.specter_error import SpecterInternalException
logger = logging.getLogger(__name__)
# Testclasses
class A:
id = "A"
depends = []
def callback_adjust_view_model(self, model):
logger.info(
f"A.callback_adjust_view_model has been called with model='{model}'!"
)
return "Hello"
class B:
id = "B"
pass
class C:
id = "C"
depends = [A]
def callback_adjust_view_model(self, model):
return model + " World"
class D:
id = "D"
depends = [C, B]
class E:
id = "E"
pass
class F:
id = "F"
depends = [A, D]
@pytest.fixture
def exts():
return [F(), E(), D(), C(), B(), A()]
@pytest.fixture
def callback_executor(exts):
service_dict = {ext.__class__.__name__: ext for ext in exts}
print(service_dict)
ce = CallbackExecutor(service_dict)
return ce
def test_check_callback(callback_executor: CallbackExecutor):
ce = callback_executor
with pytest.raises(SpecterInternalException):
ce.check_callback("muh")
ce.check_callback(flask_before_request)
ce.check_callback(adjust_view_model)
def test_topological_sort(exts):
ext_sorted = topological_sort(exts)
ext_classes = [ext.__class__ for ext in ext_sorted]
assert ext_classes == [A, C, B, D, F, E]
def test_set_dependency_level_recursive(exts):
for ext in exts:
ext.__class__.dependency_level = 0
set_dependency_level_recursive(C)
assert A.dependency_level == 1
set_dependency_level_recursive(D)
assert C.dependency_level == 1
assert B.dependency_level == 1
assert A.dependency_level == 2
set_dependency_level_recursive(F)
assert C.dependency_level == 2
assert B.dependency_level == 2
assert A.dependency_level == 3
assert D.dependency_level == 1
# Doing it again does not change the numbers
set_dependency_level_recursive(F)
assert C.dependency_level == 2
assert B.dependency_level == 2
assert A.dependency_level == 3
assert D.dependency_level == 1
def test_CallbackExecutor_services_sorted(exts):
A.sort_priority = 2
B.sort_priority = 1
E.sort_priority = 3
service_dict = {ext.__class__.__name__: ext for ext in exts}
print(service_dict)
ce = CallbackExecutor(service_dict)
assert len(ce.services_sorted) == 6
for ext in ce.services_sorted:
print(
f"{ext.__class__.__name__} {ext.__class__.dependency_level} {getattr(ext, 'sort_priority',9999)}"
)
assert [ext.__class__ for ext in ce.services_sorted] == [A, B, C, D, E, F]
def test_CallbackExecutor_execute(caplog, callback_executor):
caplog.set_level(logging.DEBUG)
ce = callback_executor
rv = ce.execute_ext_callbacks(flask_before_request, "bumm", ["diedel", "bummm"])
assert rv == {}
assert ce.execute_ext_callbacks(adjust_view_model, "") == "Hello World"
def test_topological_sort_cyclic_dependencies():
A.depends.append(F) # cyclic dependency
with pytest.raises(ValueError):
ext_sorted = topological_sort([F(), E(), D(), C(), B(), A()])
del A.depends

View file

@ -20,7 +20,7 @@ from cryptoadvance.specter.services.service_encrypted_storage import (
ServiceEncryptedStorageError,
ServiceEncryptedStorageManager,
)
from cryptoadvance.specter.managers.service_manager import ServiceManager
from cryptoadvance.specter.managers.service_manager import ExtensionManager
from cryptoadvance.specter.user import User, hash_password
@ -41,13 +41,13 @@ class FakeService(Service):
# @patch("cryptoadvance.specter.services.service_manager.app")
# def test_ServiceManager_loads_services(empty_data_folder, app):
# def test_ExtensionManager_loads_services(empty_data_folder, app):
# # app.config = MagicMock()
# # app.config.get.return_value = "prod"
# specter_mock = MagicMock()
# specter_mock.data_folder.return_value = empty_data_folder
# service_manager = ServiceManager(specter=specter_mock, devstatus_threshold="alpha")
# service_manager = ExtensionManager(specter=specter_mock, devstatus_threshold="alpha")
# services = service_manager.services
# assert "swan" in services

View file

@ -7,7 +7,6 @@ import pytest
import mock
from mock import Mock, patch
from cryptoadvance.specterext.swan.client import (
SwanApiException,
SwanApiRefreshTokenException,
SwanClient,
)

View file

@ -90,10 +90,7 @@ def test_get_subclasses_for_class(caplog):
classlist = get_subclasses_for_clazz(SpecterMigration)
assert len(classlist) >= 3
classlist = get_subclasses_for_clazz(Service)
assert len(classlist) >= 5
# checking naively for certain Services would be counterproductive if you import
# the class. THis needs to work without importing the class!
# But we can test like this:
assert "SwanService" in [cls.__name__ for cls in classlist]
classlist = get_subclasses_for_clazz(Device)
assert len(classlist) > 5
assert len(classlist) == 5 # Happy to remove that at some point
assert SwanService in classlist
assert ElectrumService in classlist
assert DevhelpService in classlist