From 8f2435b80f4ef9801a71c3b63a654d9e61c305eb Mon Sep 17 00:00:00 2001 From: Johannes Ernst Date: Tue, 8 Oct 2024 20:07:10 -0700 Subject: [PATCH] Clean up package hierarchy (#381) * Move feditest/ubos/__init__.py to feditest/nodedrivers/ubos.py -- better location * Move Node, NodeDriver and associated classes from feditest.protocols to feditest.nodedrivers -- much more logical location * Fix \ missing escape in PHP string * Add smoke tests to Makefile --------- Co-authored-by: Johannes Ernst --- Makefile | 17 +- src/feditest/__init__.py | 4 +- src/feditest/nodedrivers/__init__.py | 679 +++++++++++++++++ .../nodedrivers/fallback/fediverse.py | 2 +- src/feditest/nodedrivers/imp/__init__.py | 2 +- src/feditest/nodedrivers/manual/__init__.py | 2 +- src/feditest/nodedrivers/mastodon/__init__.py | 2 +- src/feditest/nodedrivers/mastodon/ubos.py | 30 +- src/feditest/nodedrivers/saas/__init__.py | 2 +- src/feditest/nodedrivers/sandbox/__init__.py | 2 +- .../{ubos/__init__.py => nodedrivers/ubos.py} | 2 +- .../nodedrivers/wordpress/__init__.py | 16 +- src/feditest/nodedrivers/wordpress/ubos.py | 20 +- src/feditest/protocols/__init__.py | 681 +----------------- .../protocols/activitypub/__init__.py | 4 +- src/feditest/protocols/activitypub/utils.py | 2 +- src/feditest/protocols/fediverse/__init__.py | 2 +- src/feditest/protocols/sandbox/__init__.py | 2 +- src/feditest/protocols/web/__init__.py | 2 +- src/feditest/protocols/webfinger/__init__.py | 2 +- src/feditest/testrun.py | 2 +- tests.unit/test_10_register_nodedrivers.py | 2 +- ...st_20_create_test_plan_session_template.py | 2 +- tests.unit/test_30_create_testplan.py | 2 +- .../test_40_report_node_driver_errors.py | 2 +- ...unt_manager_fallback_fediverse_accounts.py | 2 +- tests.unit/test_50_run_not_implemented.py | 2 +- tests.unit/test_50_run_skip.py | 2 +- 28 files changed, 752 insertions(+), 739 deletions(-) rename src/feditest/{ubos/__init__.py => nodedrivers/ubos.py} (99%) diff --git a/Makefile b/Makefile index 19ed698..fb5c573 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,11 @@ UNAME?=$(shell uname -s | tr [A-Z] [a-z]) BRANCH?=$(shell git branch --show-current) VENV?=venv.$(UNAME).$(BRANCH) PYTHON?=python3.11 +FEDITEST?=$(VENV)/bin/feditest -v + +default : all + +all : build lint tests build : venv $(VENV)/bin/pip install . @@ -39,9 +44,17 @@ lint : build @# MYPYPATH is needed because apparently some type checking ignores the directory option given as command-line argument @# $(VENV)/bin/pylint src -test : venv +tests : tests.unit tests.smoke + +tests.unit : venv $(VENV)/bin/pytest -v +tests.smoke : venv + $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --constellation tests.smoke/mastodon.ubos.constellation.json + $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api.session.json --constellation tests.smoke/wordpress.ubos.constellation.json + $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --constellation tests.smoke/mastodon_mastodon.ubos.constellation.json + # Currently broken: $(FEDITEST) run --testsdir tests.smoke/tests --session tests.smoke/mastodon_api_mastodon_api.session.json --constellation tests.smoke/wordpress_mastodon.ubos.constellation.json + release : @which $(PYTHON) || ( echo 'No executable called "python". Append your python to the make command, like "make PYTHON=your-python"' && false ) [[ -d venv.release ]] && rm -rf venv.release || true @@ -54,4 +67,4 @@ release : @echo The actual push to pypi.org you need to do manually. Enter: @echo venv.release/bin/twine upload dist/* -.PHONY: venv build lint test release +.PHONY: all default venv build lint tests tests.unit tests.smoke release diff --git a/src/feditest/__init__.py b/src/feditest/__init__.py index 1a708f0..cdbfb83 100644 --- a/src/feditest/__init__.py +++ b/src/feditest/__init__.py @@ -12,7 +12,7 @@ from hamcrest.core.matcher import Matcher from hamcrest.core.string_description import StringDescription -import feditest.protocols +import feditest.nodedrivers from feditest.reporting import fatal, warning from feditest.tests import ( Test, @@ -204,7 +204,7 @@ class XYZDriver : ... if not _loading_node_drivers: fatal('Do not define NodeDrivers outside of nodedriversdir') - if not issubclass(to_register,feditest.protocols.NodeDriver): + if not issubclass(to_register,feditest.nodedrivers.NodeDriver): fatal('Cannot register an object as NodeDriver that isn\'t a subclass of NodeDriver:', to_register.__name__) module = getmodule(to_register) diff --git a/src/feditest/nodedrivers/__init__.py b/src/feditest/nodedrivers/__init__.py index c0f6f28..abd74cf 100644 --- a/src/feditest/nodedrivers/__init__.py +++ b/src/feditest/nodedrivers/__init__.py @@ -1,2 +1,681 @@ """ +Define interfaces to interact with the nodes in the constellation being tested """ + +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any, cast, final + +from feditest.testplan import TestPlanConstellationNode, TestPlanNodeParameter, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField +from feditest.reporting import info, warning +from feditest.utils import hostname_validate, appname_validate, appversion_validate + + +APP_PAR = TestPlanNodeParameter( + 'app', + """Name of the app""", + validate = lambda x: len(x) +) +APP_VERSION_PAR = TestPlanNodeParameter( + 'app_version', + """Version of the app""" +) +HOSTNAME_PAR = TestPlanNodeParameter( + 'hostname', + """DNS hostname of where the app is running.""", + validate=hostname_validate +) + + +class Account(ABC): + """ + The notion of an existing account on a Node. As different Nodes have different ideas about + what they know about an Account, this is an entirey abstract base class here. + """ + def __init__(self, role: str | None): + self._role = role + self._node : 'Node' | None = None + + + @property + def role(self): + return self._role + + + def set_node(self, node: 'Node'): + """ + Set the Node at which this is an Account. This is invoked exactly once after the Node + has been instantiated (the Account is instantiated earlier). + """ + if self._node: + raise ValueError(f'Node already set {self}') + self._node = node + + + @property + def node(self): + return self._node + + +class NonExistingAccount(ABC): + """ + The notion of a non-existing account on a Node. As different Nodes have different ideas about + what they know about an Account, this is an entirey abstract base class here. + """ + def __init__(self, role: str | None): + self._role = role + self._node : 'Node' | None = None + + + @property + def role(self): + return self._role + + + def set_node(self, node: 'Node'): + """ + Set the Node at which this is a NonExistingAccount. This is invoked exactly once after the Node + has been instantiated (the NonExistingAccount is instantiated earlier). + """ + if self._node: + raise ValueError(f'Node already set {self}') + self._node = node + + @property + def node(self): + return self._node + + +class OutOfAccountsException(Exception): + """ + Thrown if another Account was requested, but no additional Account was known or could be + provisionined. + """ + ... + + +class OutOfNonExistingAccountsException(Exception): + """ + Thrown if another NonExistingAccount was requested, but no additional NonExistingAccount + was known or could be provisionined. + """ + ... + + +class AccountManager(ABC): + """ + Manages accounts on a Node. It can be implemented in a variety of ways, including + being a facade for the Node's API, or returning only Accounts pre-allocated in the + TestPlan, or dynamically provisioning accounts etc. + """ + @abstractmethod + def set_node(self, node: 'Node'): + """ + Set the Node to which this AccountManager belongs. This is invoked exactly once after the Node + has been instantiated (the AccountManager is instantiated earlier). + """ + ... + + @abstractmethod + def get_account_by_role(self, role: str | None = None) -> Account | None: + """ + If an account has been assigned to a role already, return it; + otherwise return None + """ + ... + + + @abstractmethod + def obtain_account_by_role(self, role: str | None = None) -> Account: + """ + If this method is invoked with the same role twice, it returns + the same Account. May raise OutOfAccountsException. + """ + ... + + + @abstractmethod + def get_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount | None: + """ + If a non-existing account has been assigned to a role already, return it; + otherwise return None + """ + ... + + + @abstractmethod + def obtain_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount: + """ + If this method is invoked with the same role twice, it returns + the same NonExistingAccount. May raise OutOfNonExistingAccountsException. + """ + ... + + + @abstractmethod + def get_account_by_match(self, match_function: Callable[[Account],bool]) -> Account | None: + """ + Provide a matching function. Return the first Account known by this AccountManager and + allocated to a role that matches the matching function. + """ + ... + + + @abstractmethod + def get_non_existing_account_by_match(self, match_function: Callable[[NonExistingAccount],bool]) -> NonExistingAccount | None: + """ + Provide a matching function. Return the first NonExistingAccount known by this AccountManager and + allocated to a role that matches the matching function. + """ + ... + + +class AbstractAccountManager(AccountManager): + """ + An AccountManager implementation that is initialized from lists of inital accounts and non-accounts + in a NodeConfiguration, and then dynamically allocates those Accounts and + NonExistingAccounts to the requested roles. + It has an empty method to dynamically provision new Accounts, which can be implemented + by subclasses. + As the name (but not the code) says, it is intended to be abstract. We love Python. + """ + def __init__(self, initial_accounts: list[Account], initial_non_existing_accounts: list[NonExistingAccount]): + """ + Provide the accounts and non-existing-accounts that are known to exist/not exist + when the Node is provisioned. + """ + self._accounts_allocated_to_role : dict[str | None, Account] = { account.role : account for account in initial_accounts if account.role } + self._accounts_not_allocated_to_role : list[Account] = [ account for account in initial_accounts if not account.role ] + + self._non_existing_accounts_allocated_to_role : dict[str | None, NonExistingAccount] = { non_account.role : non_account for non_account in initial_non_existing_accounts if non_account.role } + self._non_existing_accounts_not_allocated_to_role : list[NonExistingAccount ]= [ non_account for non_account in initial_non_existing_accounts if not non_account.role ] + + self._node : Node | None = None # the Node this AccountManager belongs to. Set once the Node has been instantiated + + + # Python 3.12 @override + def set_node(self, node: 'Node') -> None: + if self._node: + raise ValueError('Have Node already') + self._node = node + + for account in self._accounts_allocated_to_role.values(): + account.set_node(self._node) + for account in self._accounts_not_allocated_to_role: + account.set_node(self._node) + for non_existing_account in self._non_existing_accounts_allocated_to_role.values(): + non_existing_account.set_node(self._node) + for non_existing_account in self._non_existing_accounts_not_allocated_to_role: + non_existing_account.set_node(self._node) + + + # Python 3.12 @override + def get_account_by_role(self, role: str | None = None) -> Account | None: + return self._accounts_allocated_to_role.get(role) + + + # Python 3.12 @override + def obtain_account_by_role(self, role: str | None = None) -> Account: + ret = self._accounts_allocated_to_role.get(role) + if not ret: + if self._accounts_not_allocated_to_role: + ret = self._accounts_not_allocated_to_role.pop(0) + self._accounts_allocated_to_role[role] = ret + else: + ret = self._provision_account_for_role(role) + if ret: + if ret.node is None: # the Node may already have assigned it + ret.set_node(cast(Node, self._node)) # by now it is not None + self._accounts_allocated_to_role[role] = ret + if ret: + return ret + raise OutOfAccountsException() + + + # Python 3.12 @override + def get_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount | None: + return self._non_existing_accounts_allocated_to_role.get(role) + + + # Python 3.12 @override + def obtain_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount: + ret = self._non_existing_accounts_allocated_to_role.get(role) + if not ret: + if self._non_existing_accounts_not_allocated_to_role: + ret = self._non_existing_accounts_not_allocated_to_role.pop(0) + self._non_existing_accounts_allocated_to_role[role] = ret + else: + ret = self._provision_non_existing_account_for_role(role) + if ret: + if ret.node is None: # the Node may already have assigned it + ret.set_node(cast(Node, self._node)) # by now it is not None + self._non_existing_accounts_allocated_to_role[role] = ret + if ret: + return ret + raise OutOfNonExistingAccountsException() + + + # Python 3.12 @override + def get_account_by_match(self, match_function: Callable[[Account],bool]) -> Account | None: + for account in self._accounts_allocated_to_role.values(): + if match_function(account): + return account + return None + + + # Python 3.12 @override + def get_non_existing_account_by_match(self, match_function: Callable[[NonExistingAccount],bool]) -> NonExistingAccount | None: + for non_existing_account in self._non_existing_accounts_allocated_to_role.values(): + if match_function(non_existing_account): + return non_existing_account + return None + + + @abstractmethod + def _provision_account_for_role(self, role: str | None = None) -> Account | None: + """ + This can be overridden by subclasses to dynamically provision a new Account. + By default, we ask our Node. + """ + ... + + + @abstractmethod + def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: + """ + This can be overridden by subclasses to dynamically provision a new NonExistingAccount. + By default, we ask our Node. + """ + ... + + +class DefaultAccountManager(AbstractAccountManager): + """ + An AccountManager that asks the Node to provision accounts. + """ + def _provision_account_for_role(self, role: str | None = None) -> Account | None: + node = cast(Node, self._node) + return node.provision_account_for_role(role) + + + def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: + node = cast(Node, self._node) + return node.provision_non_existing_account_for_role(role) + + +class StaticAccountManager(AbstractAccountManager): + """ + An AccountManager that only uses the static informatation about Accounts and NonExistingAccounts + that was provided in the TestPlan. + """ + def _provision_account_for_role(self, role: str | None = None) -> Account | None: + return None + + + def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: + return None + + +class NodeConfiguration: + """ + Collects all information about a Node so that the Node can be instantiated. + This is an abstract concept; specific Node subclasses will have their own subclasses. + On this level, just a few properties have been defined that are commonly used. + + (Maybe this could be a @dataclass: not sure how exactly that works with ABC and subclasses + so I rather not try) + """ + def __init__(self, + node_driver: 'NodeDriver', + app: str, + app_version: str | None = None, + hostname: str | None = None, + start_delay: float = 0.0 + ): + if app and not appname_validate(app): + raise NodeSpecificationInvalidError(node_driver, 'app', app) + if app_version and not appversion_validate(app_version): + raise NodeSpecificationInvalidError(node_driver, 'app_version', app_version) + if hostname and not hostname_validate(hostname): + raise NodeSpecificationInvalidError(node_driver, 'hostname', hostname) + + self._node_driver = node_driver + self._app = app + self._app_version = app_version + self._hostname = hostname + self._start_delay = start_delay + + + @property + def node_driver(self) -> 'NodeDriver': + return self._node_driver + + + @property + def app(self) -> str: + return self._app + + + @property + def app_version(self) -> str | None: + return self._app_version + + + @property + def hostname(self) -> str | None: + return self._hostname + + + @property + def start_delay(self) -> float: + return self._start_delay + + + def __str__(self) -> str: + return f'NodeConfiguration ({ type(self).__name__ }): node driver: "{ self.node_driver }", app: "{ self.app }", hostname: "{ self.hostname }"' + + +class Node(ABC): + """ + A Node is the interface through which FediTest talks to an application instance. + Node itself is an abstract superclass. + + There are (also abstract) sub-classes that provide methods specific to specific + protocols to be tested. Each such protocol has a sub-class for each role in the + protocol. For example, client-server protocols have two different subclasses of + Node, one for the client, and one for the server. + + Any application that wishes to benefit from automated test execution with FediTest + needs to define for itself a subclass of each protocol-specific subclass of Node + so FediTest can control and observe what it needs to when attempting to + participate with the respective protocol. + """ + def __init__(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None = None): + """ + rolename: name of the role in the constellation + config: the previously created configuration object for this Node + account_manager: use this AccountManager + """ + if not rolename: + raise Exception('Required: rolename') + if not config: # not trusting the linter + raise Exception('Required: config') + + self._rolename = rolename + self._config = config + if account_manager: + self._account_manager = account_manager + self._account_manager.set_node(self) + + + @property + def rolename(self): + return self._rolename + + + @property + def config(self): + return self._config + + + @property + def hostname(self): + return self._config.hostname + + + @property + def node_driver(self): + return self._config.node_driver + + + @property + def account_manager(self) -> AccountManager | None: + return self._account_manager + + + def provision_account_for_role(self, role: str | None = None) -> Account | None: + """ + We need a new Account on this Node, for the given role. Provision that account, + or return None if not possible. By default, we ask the user. + """ + return None + + + def provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: + """ + We need a new NonExistingAccount on this Node, for the given role. Return information about that + non-existing account, or return None if not possible. By default, we ask the user. + """ + return None + + + def add_cert_to_trust_store(self, root_cert: str) -> None: + self.prompt_user(f'Please add this temporary certificate to the trust root of node { self } and hit return when done:\n' + root_cert) + + + def remove_cert_from_trust_store(self, root_cert: str) -> None: + self.prompt_user(f'Please remove this previously-added temporary certificate from the trust store of node { self } and hit return when done:\n' + root_cert) + + + def __str__(self) -> str: + if self._config.hostname: + return f'"{ type(self).__name__}", hostname "{ self._config.hostname }" in constellation role "{self.rolename}"' + return f'"{ type(self).__name__}" in constellation role "{self.rolename}"' + + + def prompt_user(self, question: str, value_if_known: Any | None = None, parse_validate: Callable[[str],Any] | None = None) -> Any | None: + """ + If an Node does not natively implement support for a particular method, + this method is invoked as a fallback. It prompts the user to enter information + at the console. + + question: the text to be emitted to the user as a prompt + value_if_known: if given, that value can be used instead of asking the user + parse_validate: optional function that attempts to parse and validate the provided user input. + If the value is valid, it parses the value and returns the parsed version. If not valid, it returns None. + return: the value entered by the user, parsed, or None + """ + return self.node_driver.prompt_user(question, value_if_known, parse_validate) + + +class NodeDriver(ABC): + """ + This is an abstract superclass for all objects that know how to instantiate Nodes of some kind. + Any one subclass of NodeDriver is only instantiated once as a singleton + """ + @staticmethod + def test_plan_node_parameters() -> list[TestPlanNodeParameter]: + """ + Return the TestPlanNodeParameters that may be specified on TestPlanConstellationNodes. + This is used by "feditest info --nodedriver" to help the user figure out what parameters + to specify and what their names are. + """ + return [ APP_PAR, APP_VERSION_PAR, HOSTNAME_PAR ] + + + @staticmethod + def test_plan_node_account_fields() -> list[TestPlanNodeAccountField]: + """ + Return the TestPlanNodeAccountFields that may be specified on TestPlanConstellationNodes to identify existing Accounts. + This is used by "feditest info --nodedriver" to help the user figure out how to specify + pre-existing Accounts on a Node. + """ + return [] # By default: cannot be done + + + @staticmethod + def test_plan_node_non_existing_account_fields() -> list[TestPlanNodeNonExistingAccountField]: + """ + Return the TestPlanNodeNonExistingAccountFields that may be specified on TestPlanConstellationNodes to identify non-existing Accounts. + This is used by "feditest info --nodedriver" to help the user figure out how to specify + non-existing Accounts on a Node. + """ + return [] # By default: cannot be done + + + def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]: + """ + Read the node data provided in test_plan_node and create a NodeConfiguration object + from it. This will throw exceptions if the Node is misconfigured. + + May be overridden in subclasses. + """ + return ( + NodeConfiguration( + self, + test_plan_node.parameter_or_raise(APP_PAR), + test_plan_node.parameter(APP_VERSION_PAR), + test_plan_node.parameter(HOSTNAME_PAR) + ), + None + ) + + + @final + def provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None = None) -> Node: + """ + Instantiate a Node + rolename: the name of this Node in the constellation + config: the NodeConfiguration created with create_configuration + """ + info(f'Provisioning node for role "{ rolename }" with { config }.') + ret = self._provision_node(rolename, config, account_manager) + return ret + + + @final + def unprovision_node(self, node: Node) -> None: + """ + Deactivate and delete a Node + node: the Node + """ + if node.node_driver != self : + raise Exception(f"Node does not belong to this NodeDriver: { node.node_driver } vs { self }") # pylint: disable=broad-exception-raised + + info(f'Unprovisioning node for role "{ node.rolename }" with NodeDriver "{ self.__class__.__name__}".') + self._unprovision_node(node) + + + def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> Node: + """ + The factory method for Node. Any subclass of NodeDriver should also + override this and return a more specific subclass of IUT. + """ + raise NotImplementedByNodeDriverError(self, NodeDriver._provision_node) + + + def _unprovision_node(self, node: Node) -> None: + """ + Invoked when a Node gets unprovisioned, in case cleanup needs to be performed. + This is here so subclasses of NodeDriver can override it. + """ + # by default, do nothing + pass # pylint: disable=unnecessary-pass + + + def prompt_user(self, question: str, value_if_known: Any | None = None, parse_validate: Callable[[str],Any] | None = None) -> Any | None: + """ + If an NodeDriver does not natively implement support for a particular method, + this method is invoked as a fallback. It prompts the user to enter information + at the console. + + This is implemented on NodeDriver rather than Node, so we can also ask + provisioning-related questions. + + question: the text to be emitted to the user as a prompt + value_if_known: if given, that value can be used instead of asking the user + parse_validate: optional function that attempts to parse and validate the provided user input. + If the value is valid, it parses the value and returns the parsed version. If not valid, it returns None. + return: the value entered by the user, parsed, or None + """ + if value_if_known: + if parse_validate is None: + return value_if_known + ret_parsed = parse_validate(value_if_known) + if ret_parsed is not None: + return ret_parsed + warning(f'Preconfigured value "{ value_if_known }" is invalid, ignoring.') + + while True: + ret = input(f'TESTER ACTION REQUIRED: { question }') + if parse_validate is None: + return ret + ret_parsed = parse_validate(ret) + if ret_parsed is not None: + return ret_parsed + print(f'INPUT ERROR: invalid input, try again. Was: "{ ret }"') + + + def __str__(self) -> str: + return self.__class__.__name__ + + +class SkipTestException(Exception): + """ + Indicates that the test wanted to be skipped. It can be thrown if the test recognizes + the circumstances in which it should be run are not currently present. + Modeled after https://github.com/hamcrest/PyHamcrest/blob/main/src/hamcrest/core/assert_that.py + """ + def __init__(self, msg: str) : + """ + Provide reasoning why this test was skipped. + """ + super().__init__(msg) + + +class NotImplementedByNodeOrDriverError(SkipTestException): + pass + + +class NotImplementedByNodeError(NotImplementedByNodeOrDriverError): + """ + This exception is raised when a Node cannot perform a certain operation because it + has not been implemented in this subtype of Node. + """ + def __init__(self, node: Node, method: Callable[...,Any], arg: Any = None ): + super().__init__(f"Not implemented by node {node}: {method.__name__}" + (f" ({ arg })" if arg else "")) + + +class NotImplementedByNodeDriverError(NotImplementedByNodeOrDriverError): + """ + This exception is raised when a Node cannot perform a certain operation because it + has not been implemented in this subtype of Node. + """ + def __init__(self, node_driver: NodeDriver, method: Callable[...,Any], arg: Any = None ): + super().__init__(f"Not implemented by node driver {node_driver}: {method.__name__}" + (f" ({ arg })" if arg else "")) + + +class NodeOutOfAccountsException(RuntimeError): + """ + A test wanted to obtain an (or obtain another) account on this Node, but no account was + known, no account could be automatically provisioned, or all known or provisionable + accounts were returned already. + """ + def __init__(self, node: NodeDriver, rolename: str ): + super().__init__(f"Out of accounts on Node { node }, account role { rolename }" ) + + +class NodeSpecificationInsufficientError(RuntimeError): + """ + This exception is raised when a NodeDriver cannot instantiate a Node because insufficient + information (parameters) has been provided. + """ + def __init__(self, node_driver: NodeDriver, details: str ): + super().__init__(f"Node specification is insufficient for {node_driver}: {details}" ) + + +class NodeSpecificationInvalidError(RuntimeError): + """ + This exception is raised when a NodeDriver cannot instantiate a Node because invalid + information (e.g. a syntax error in a parameter) has been provided. + """ + def __init__(self, node_driver: NodeDriver, parameter: str, details: str ): + super().__init__(f"Node specification is invalid for {node_driver}, parameter {parameter}: {details}" ) + + +class TimeoutException(RuntimeError): + """ + A result has not arrived within the expected time period. + """ + def __init__(self, msg: str, timeout: float): + super().__init__(f'{ msg } (timeout: { timeout })') diff --git a/src/feditest/nodedrivers/fallback/fediverse.py b/src/feditest/nodedrivers/fallback/fediverse.py index f64b3d9..082a87b 100644 --- a/src/feditest/nodedrivers/fallback/fediverse.py +++ b/src/feditest/nodedrivers/fallback/fediverse.py @@ -4,7 +4,7 @@ from typing import cast -from feditest.protocols import ( +from feditest.nodedrivers import ( Account, AccountManager, DefaultAccountManager, diff --git a/src/feditest/nodedrivers/imp/__init__.py b/src/feditest/nodedrivers/imp/__init__.py index bd57244..b23c068 100644 --- a/src/feditest/nodedrivers/imp/__init__.py +++ b/src/feditest/nodedrivers/imp/__init__.py @@ -7,7 +7,7 @@ import httpx from multidict import MultiDict -from feditest.protocols import AccountManager, Node, NodeConfiguration, NodeDriver, HOSTNAME_PAR +from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver, HOSTNAME_PAR from feditest.protocols.web import ParsedUri, WebClient from feditest.protocols.web.traffic import ( HttpRequest, diff --git a/src/feditest/nodedrivers/manual/__init__.py b/src/feditest/nodedrivers/manual/__init__.py index e585c2b..9a0a55d 100644 --- a/src/feditest/nodedrivers/manual/__init__.py +++ b/src/feditest/nodedrivers/manual/__init__.py @@ -2,8 +2,8 @@ A NodeDriver that supports all protocols but doesn't automate anything. """ +from feditest.nodedrivers import AccountManager, Node, NodeConfiguration from feditest.nodedrivers.fallback.fediverse import AbstractFallbackFediverseNodeDriver, FallbackFediverseNode -from feditest.protocols import AccountManager, Node, NodeConfiguration from feditest.protocols.fediverse import FediverseNode diff --git a/src/feditest/nodedrivers/mastodon/__init__.py b/src/feditest/nodedrivers/mastodon/__init__.py index f7d2742..a604ad7 100644 --- a/src/feditest/nodedrivers/mastodon/__init__.py +++ b/src/feditest/nodedrivers/mastodon/__init__.py @@ -12,7 +12,7 @@ from typing import Any, Callable, cast from feditest import AssertionFailure, InteropLevel, SpecLevel -from feditest.protocols import ( +from feditest.nodedrivers import ( Account, AccountManager, DefaultAccountManager, diff --git a/src/feditest/nodedrivers/mastodon/ubos.py b/src/feditest/nodedrivers/mastodon/ubos.py index d3a8251..3024655 100644 --- a/src/feditest/nodedrivers/mastodon/ubos.py +++ b/src/feditest/nodedrivers/mastodon/ubos.py @@ -7,6 +7,17 @@ import subprocess from typing import Any, cast +from feditest.nodedrivers import ( + Account, + NonExistingAccount, + AccountManager, + DefaultAccountManager, + Node, + NodeConfiguration, + APP_PAR, + APP_VERSION_PAR, + HOSTNAME_PAR, +) from feditest.nodedrivers.mastodon import ( MastodonAccount, MastodonNode, @@ -21,21 +32,7 @@ USERID_ACCOUNT_FIELD, USERID_NON_EXISTING_ACCOUNT_FIELD ) -from feditest.protocols import ( - Account, - NonExistingAccount, - AccountManager, - DefaultAccountManager, - Node, - NodeConfiguration, - APP_PAR, - APP_VERSION_PAR, - HOSTNAME_PAR, -) -from feditest.registry import registry_singleton -from feditest.reporting import error, trace -from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameterMalformedError -from feditest.ubos import ( +from feditest.nodedrivers.ubos import ( UbosNodeConfiguration, UbosNodeDeployConfiguration, UbosNodeDriver, @@ -52,6 +49,9 @@ TLSCERT_PAR, TLSKEY_PAR ) +from feditest.registry import registry_singleton +from feditest.reporting import error, trace +from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameterMalformedError class MastodonUbosNodeConfiguration(UbosNodeDeployConfiguration, NodeWithMastodonApiConfiguration): diff --git a/src/feditest/nodedrivers/saas/__init__.py b/src/feditest/nodedrivers/saas/__init__.py index 16bd2c5..c32a430 100644 --- a/src/feditest/nodedrivers/saas/__init__.py +++ b/src/feditest/nodedrivers/saas/__init__.py @@ -1,8 +1,8 @@ """ """ +from feditest.nodedrivers import AccountManager, NodeConfiguration from feditest.nodedrivers.fallback.fediverse import AbstractFallbackFediverseNodeDriver, FallbackFediverseNode -from feditest.protocols import AccountManager, NodeConfiguration class FediverseSaasNodeDriver(AbstractFallbackFediverseNodeDriver): diff --git a/src/feditest/nodedrivers/sandbox/__init__.py b/src/feditest/nodedrivers/sandbox/__init__.py index 91982ef..0874737 100644 --- a/src/feditest/nodedrivers/sandbox/__init__.py +++ b/src/feditest/nodedrivers/sandbox/__init__.py @@ -3,7 +3,7 @@ from typing import List -from feditest.protocols import AccountManager, NodeConfiguration, NodeDriver, HOSTNAME_PAR +from feditest.nodedrivers import AccountManager, NodeConfiguration, NodeDriver, HOSTNAME_PAR from feditest.protocols.sandbox import SandboxLogEvent, SandboxMultClient, SandboxMultServer from feditest.testplan import TestPlanConstellationNode, TestPlanNodeParameter from feditest.utils import FEDITEST_VERSION diff --git a/src/feditest/ubos/__init__.py b/src/feditest/nodedrivers/ubos.py similarity index 99% rename from src/feditest/ubos/__init__.py rename to src/feditest/nodedrivers/ubos.py index 03ea1fa..aa2dd96 100644 --- a/src/feditest/ubos/__init__.py +++ b/src/feditest/nodedrivers/ubos.py @@ -13,7 +13,7 @@ import string from typing import Any, cast -from feditest.protocols import ( +from feditest.nodedrivers import ( APP_PAR, APP_VERSION_PAR, HOSTNAME_PAR, diff --git a/src/feditest/nodedrivers/wordpress/__init__.py b/src/feditest/nodedrivers/wordpress/__init__.py index 9270e36..b46d6df 100644 --- a/src/feditest/nodedrivers/wordpress/__init__.py +++ b/src/feditest/nodedrivers/wordpress/__init__.py @@ -4,14 +4,7 @@ import re from typing import cast -from feditest.nodedrivers.mastodon import ( - AccountOnNodeWithMastodonAPI, - Mastodon, # Re-import from there to avoid duplicating the package import hackery - MastodonOAuthApp, - NodeWithMastodonAPI, - NodeWithMastodonApiConfiguration -) -from feditest.protocols import ( +from feditest.nodedrivers import ( Account, AccountManager, DefaultAccountManager, @@ -22,6 +15,13 @@ APP_VERSION_PAR, HOSTNAME_PAR ) +from feditest.nodedrivers.mastodon import ( + AccountOnNodeWithMastodonAPI, + Mastodon, # Re-import from there to avoid duplicating the package import hackery + MastodonOAuthApp, + NodeWithMastodonAPI, + NodeWithMastodonApiConfiguration +) from feditest.protocols.fediverse import FediverseNode from feditest.reporting import is_trace_active, trace from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField, TestPlanNodeParameter diff --git a/src/feditest/nodedrivers/wordpress/ubos.py b/src/feditest/nodedrivers/wordpress/ubos.py index 5f9f3d8..b0c35fb 100644 --- a/src/feditest/nodedrivers/wordpress/ubos.py +++ b/src/feditest/nodedrivers/wordpress/ubos.py @@ -4,7 +4,16 @@ import os from typing import cast +from feditest.nodedrivers import ( + Account, + AccountManager, + DefaultAccountManager, + NonExistingAccount, + Node, + NodeConfiguration +) from feditest.nodedrivers.mastodon.ubos import MastodonUbosNodeConfiguration +from feditest.nodedrivers.ubos import UbosNodeConfiguration, UbosNodeDriver from feditest.nodedrivers.wordpress import ( OAUTH_TOKEN_ACCOUNT_FIELD, ROLE_ACCOUNT_FIELD, @@ -15,17 +24,8 @@ WordPressNonExistingAccount, WordPressPlusPluginsNode ) -from feditest.protocols import ( - Account, - AccountManager, - DefaultAccountManager, - NonExistingAccount, - Node, - NodeConfiguration -) from feditest.reporting import trace from feditest.testplan import TestPlanConstellationNode, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField -from feditest.ubos import UbosNodeConfiguration, UbosNodeDriver @@ -94,7 +94,7 @@ def _provision_oauth_token_for(self, account: WordPressAccount, oauth_client_id: include 'wp-load.php'; -$oauth = new Enable_Mastodon_Apps\Mastodon_OAuth(); +$oauth = new Enable_Mastodon_Apps\\Mastodon_OAuth(); $oauth->get_token_storage()->setAccessToken( "{ token }", "{ oauth_client_id }", { account.internal_userid }, time() + HOUR_IN_SECONDS, 'read write follow push' ); """ dir = f'/ubos/http/sites/{ config.siteid }' diff --git a/src/feditest/protocols/__init__.py b/src/feditest/protocols/__init__.py index abd74cf..5a4829a 100644 --- a/src/feditest/protocols/__init__.py +++ b/src/feditest/protocols/__init__.py @@ -1,681 +1,2 @@ """ -Define interfaces to interact with the nodes in the constellation being tested -""" - -from abc import ABC, abstractmethod -from collections.abc import Callable -from typing import Any, cast, final - -from feditest.testplan import TestPlanConstellationNode, TestPlanNodeParameter, TestPlanNodeAccountField, TestPlanNodeNonExistingAccountField -from feditest.reporting import info, warning -from feditest.utils import hostname_validate, appname_validate, appversion_validate - - -APP_PAR = TestPlanNodeParameter( - 'app', - """Name of the app""", - validate = lambda x: len(x) -) -APP_VERSION_PAR = TestPlanNodeParameter( - 'app_version', - """Version of the app""" -) -HOSTNAME_PAR = TestPlanNodeParameter( - 'hostname', - """DNS hostname of where the app is running.""", - validate=hostname_validate -) - - -class Account(ABC): - """ - The notion of an existing account on a Node. As different Nodes have different ideas about - what they know about an Account, this is an entirey abstract base class here. - """ - def __init__(self, role: str | None): - self._role = role - self._node : 'Node' | None = None - - - @property - def role(self): - return self._role - - - def set_node(self, node: 'Node'): - """ - Set the Node at which this is an Account. This is invoked exactly once after the Node - has been instantiated (the Account is instantiated earlier). - """ - if self._node: - raise ValueError(f'Node already set {self}') - self._node = node - - - @property - def node(self): - return self._node - - -class NonExistingAccount(ABC): - """ - The notion of a non-existing account on a Node. As different Nodes have different ideas about - what they know about an Account, this is an entirey abstract base class here. - """ - def __init__(self, role: str | None): - self._role = role - self._node : 'Node' | None = None - - - @property - def role(self): - return self._role - - - def set_node(self, node: 'Node'): - """ - Set the Node at which this is a NonExistingAccount. This is invoked exactly once after the Node - has been instantiated (the NonExistingAccount is instantiated earlier). - """ - if self._node: - raise ValueError(f'Node already set {self}') - self._node = node - - @property - def node(self): - return self._node - - -class OutOfAccountsException(Exception): - """ - Thrown if another Account was requested, but no additional Account was known or could be - provisionined. - """ - ... - - -class OutOfNonExistingAccountsException(Exception): - """ - Thrown if another NonExistingAccount was requested, but no additional NonExistingAccount - was known or could be provisionined. - """ - ... - - -class AccountManager(ABC): - """ - Manages accounts on a Node. It can be implemented in a variety of ways, including - being a facade for the Node's API, or returning only Accounts pre-allocated in the - TestPlan, or dynamically provisioning accounts etc. - """ - @abstractmethod - def set_node(self, node: 'Node'): - """ - Set the Node to which this AccountManager belongs. This is invoked exactly once after the Node - has been instantiated (the AccountManager is instantiated earlier). - """ - ... - - @abstractmethod - def get_account_by_role(self, role: str | None = None) -> Account | None: - """ - If an account has been assigned to a role already, return it; - otherwise return None - """ - ... - - - @abstractmethod - def obtain_account_by_role(self, role: str | None = None) -> Account: - """ - If this method is invoked with the same role twice, it returns - the same Account. May raise OutOfAccountsException. - """ - ... - - - @abstractmethod - def get_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount | None: - """ - If a non-existing account has been assigned to a role already, return it; - otherwise return None - """ - ... - - - @abstractmethod - def obtain_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount: - """ - If this method is invoked with the same role twice, it returns - the same NonExistingAccount. May raise OutOfNonExistingAccountsException. - """ - ... - - - @abstractmethod - def get_account_by_match(self, match_function: Callable[[Account],bool]) -> Account | None: - """ - Provide a matching function. Return the first Account known by this AccountManager and - allocated to a role that matches the matching function. - """ - ... - - - @abstractmethod - def get_non_existing_account_by_match(self, match_function: Callable[[NonExistingAccount],bool]) -> NonExistingAccount | None: - """ - Provide a matching function. Return the first NonExistingAccount known by this AccountManager and - allocated to a role that matches the matching function. - """ - ... - - -class AbstractAccountManager(AccountManager): - """ - An AccountManager implementation that is initialized from lists of inital accounts and non-accounts - in a NodeConfiguration, and then dynamically allocates those Accounts and - NonExistingAccounts to the requested roles. - It has an empty method to dynamically provision new Accounts, which can be implemented - by subclasses. - As the name (but not the code) says, it is intended to be abstract. We love Python. - """ - def __init__(self, initial_accounts: list[Account], initial_non_existing_accounts: list[NonExistingAccount]): - """ - Provide the accounts and non-existing-accounts that are known to exist/not exist - when the Node is provisioned. - """ - self._accounts_allocated_to_role : dict[str | None, Account] = { account.role : account for account in initial_accounts if account.role } - self._accounts_not_allocated_to_role : list[Account] = [ account for account in initial_accounts if not account.role ] - - self._non_existing_accounts_allocated_to_role : dict[str | None, NonExistingAccount] = { non_account.role : non_account for non_account in initial_non_existing_accounts if non_account.role } - self._non_existing_accounts_not_allocated_to_role : list[NonExistingAccount ]= [ non_account for non_account in initial_non_existing_accounts if not non_account.role ] - - self._node : Node | None = None # the Node this AccountManager belongs to. Set once the Node has been instantiated - - - # Python 3.12 @override - def set_node(self, node: 'Node') -> None: - if self._node: - raise ValueError('Have Node already') - self._node = node - - for account in self._accounts_allocated_to_role.values(): - account.set_node(self._node) - for account in self._accounts_not_allocated_to_role: - account.set_node(self._node) - for non_existing_account in self._non_existing_accounts_allocated_to_role.values(): - non_existing_account.set_node(self._node) - for non_existing_account in self._non_existing_accounts_not_allocated_to_role: - non_existing_account.set_node(self._node) - - - # Python 3.12 @override - def get_account_by_role(self, role: str | None = None) -> Account | None: - return self._accounts_allocated_to_role.get(role) - - - # Python 3.12 @override - def obtain_account_by_role(self, role: str | None = None) -> Account: - ret = self._accounts_allocated_to_role.get(role) - if not ret: - if self._accounts_not_allocated_to_role: - ret = self._accounts_not_allocated_to_role.pop(0) - self._accounts_allocated_to_role[role] = ret - else: - ret = self._provision_account_for_role(role) - if ret: - if ret.node is None: # the Node may already have assigned it - ret.set_node(cast(Node, self._node)) # by now it is not None - self._accounts_allocated_to_role[role] = ret - if ret: - return ret - raise OutOfAccountsException() - - - # Python 3.12 @override - def get_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount | None: - return self._non_existing_accounts_allocated_to_role.get(role) - - - # Python 3.12 @override - def obtain_non_existing_account_by_role(self, role: str | None = None) -> NonExistingAccount: - ret = self._non_existing_accounts_allocated_to_role.get(role) - if not ret: - if self._non_existing_accounts_not_allocated_to_role: - ret = self._non_existing_accounts_not_allocated_to_role.pop(0) - self._non_existing_accounts_allocated_to_role[role] = ret - else: - ret = self._provision_non_existing_account_for_role(role) - if ret: - if ret.node is None: # the Node may already have assigned it - ret.set_node(cast(Node, self._node)) # by now it is not None - self._non_existing_accounts_allocated_to_role[role] = ret - if ret: - return ret - raise OutOfNonExistingAccountsException() - - - # Python 3.12 @override - def get_account_by_match(self, match_function: Callable[[Account],bool]) -> Account | None: - for account in self._accounts_allocated_to_role.values(): - if match_function(account): - return account - return None - - - # Python 3.12 @override - def get_non_existing_account_by_match(self, match_function: Callable[[NonExistingAccount],bool]) -> NonExistingAccount | None: - for non_existing_account in self._non_existing_accounts_allocated_to_role.values(): - if match_function(non_existing_account): - return non_existing_account - return None - - - @abstractmethod - def _provision_account_for_role(self, role: str | None = None) -> Account | None: - """ - This can be overridden by subclasses to dynamically provision a new Account. - By default, we ask our Node. - """ - ... - - - @abstractmethod - def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: - """ - This can be overridden by subclasses to dynamically provision a new NonExistingAccount. - By default, we ask our Node. - """ - ... - - -class DefaultAccountManager(AbstractAccountManager): - """ - An AccountManager that asks the Node to provision accounts. - """ - def _provision_account_for_role(self, role: str | None = None) -> Account | None: - node = cast(Node, self._node) - return node.provision_account_for_role(role) - - - def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: - node = cast(Node, self._node) - return node.provision_non_existing_account_for_role(role) - - -class StaticAccountManager(AbstractAccountManager): - """ - An AccountManager that only uses the static informatation about Accounts and NonExistingAccounts - that was provided in the TestPlan. - """ - def _provision_account_for_role(self, role: str | None = None) -> Account | None: - return None - - - def _provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: - return None - - -class NodeConfiguration: - """ - Collects all information about a Node so that the Node can be instantiated. - This is an abstract concept; specific Node subclasses will have their own subclasses. - On this level, just a few properties have been defined that are commonly used. - - (Maybe this could be a @dataclass: not sure how exactly that works with ABC and subclasses - so I rather not try) - """ - def __init__(self, - node_driver: 'NodeDriver', - app: str, - app_version: str | None = None, - hostname: str | None = None, - start_delay: float = 0.0 - ): - if app and not appname_validate(app): - raise NodeSpecificationInvalidError(node_driver, 'app', app) - if app_version and not appversion_validate(app_version): - raise NodeSpecificationInvalidError(node_driver, 'app_version', app_version) - if hostname and not hostname_validate(hostname): - raise NodeSpecificationInvalidError(node_driver, 'hostname', hostname) - - self._node_driver = node_driver - self._app = app - self._app_version = app_version - self._hostname = hostname - self._start_delay = start_delay - - - @property - def node_driver(self) -> 'NodeDriver': - return self._node_driver - - - @property - def app(self) -> str: - return self._app - - - @property - def app_version(self) -> str | None: - return self._app_version - - - @property - def hostname(self) -> str | None: - return self._hostname - - - @property - def start_delay(self) -> float: - return self._start_delay - - - def __str__(self) -> str: - return f'NodeConfiguration ({ type(self).__name__ }): node driver: "{ self.node_driver }", app: "{ self.app }", hostname: "{ self.hostname }"' - - -class Node(ABC): - """ - A Node is the interface through which FediTest talks to an application instance. - Node itself is an abstract superclass. - - There are (also abstract) sub-classes that provide methods specific to specific - protocols to be tested. Each such protocol has a sub-class for each role in the - protocol. For example, client-server protocols have two different subclasses of - Node, one for the client, and one for the server. - - Any application that wishes to benefit from automated test execution with FediTest - needs to define for itself a subclass of each protocol-specific subclass of Node - so FediTest can control and observe what it needs to when attempting to - participate with the respective protocol. - """ - def __init__(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None = None): - """ - rolename: name of the role in the constellation - config: the previously created configuration object for this Node - account_manager: use this AccountManager - """ - if not rolename: - raise Exception('Required: rolename') - if not config: # not trusting the linter - raise Exception('Required: config') - - self._rolename = rolename - self._config = config - if account_manager: - self._account_manager = account_manager - self._account_manager.set_node(self) - - - @property - def rolename(self): - return self._rolename - - - @property - def config(self): - return self._config - - - @property - def hostname(self): - return self._config.hostname - - - @property - def node_driver(self): - return self._config.node_driver - - - @property - def account_manager(self) -> AccountManager | None: - return self._account_manager - - - def provision_account_for_role(self, role: str | None = None) -> Account | None: - """ - We need a new Account on this Node, for the given role. Provision that account, - or return None if not possible. By default, we ask the user. - """ - return None - - - def provision_non_existing_account_for_role(self, role: str | None = None) -> NonExistingAccount | None: - """ - We need a new NonExistingAccount on this Node, for the given role. Return information about that - non-existing account, or return None if not possible. By default, we ask the user. - """ - return None - - - def add_cert_to_trust_store(self, root_cert: str) -> None: - self.prompt_user(f'Please add this temporary certificate to the trust root of node { self } and hit return when done:\n' + root_cert) - - - def remove_cert_from_trust_store(self, root_cert: str) -> None: - self.prompt_user(f'Please remove this previously-added temporary certificate from the trust store of node { self } and hit return when done:\n' + root_cert) - - - def __str__(self) -> str: - if self._config.hostname: - return f'"{ type(self).__name__}", hostname "{ self._config.hostname }" in constellation role "{self.rolename}"' - return f'"{ type(self).__name__}" in constellation role "{self.rolename}"' - - - def prompt_user(self, question: str, value_if_known: Any | None = None, parse_validate: Callable[[str],Any] | None = None) -> Any | None: - """ - If an Node does not natively implement support for a particular method, - this method is invoked as a fallback. It prompts the user to enter information - at the console. - - question: the text to be emitted to the user as a prompt - value_if_known: if given, that value can be used instead of asking the user - parse_validate: optional function that attempts to parse and validate the provided user input. - If the value is valid, it parses the value and returns the parsed version. If not valid, it returns None. - return: the value entered by the user, parsed, or None - """ - return self.node_driver.prompt_user(question, value_if_known, parse_validate) - - -class NodeDriver(ABC): - """ - This is an abstract superclass for all objects that know how to instantiate Nodes of some kind. - Any one subclass of NodeDriver is only instantiated once as a singleton - """ - @staticmethod - def test_plan_node_parameters() -> list[TestPlanNodeParameter]: - """ - Return the TestPlanNodeParameters that may be specified on TestPlanConstellationNodes. - This is used by "feditest info --nodedriver" to help the user figure out what parameters - to specify and what their names are. - """ - return [ APP_PAR, APP_VERSION_PAR, HOSTNAME_PAR ] - - - @staticmethod - def test_plan_node_account_fields() -> list[TestPlanNodeAccountField]: - """ - Return the TestPlanNodeAccountFields that may be specified on TestPlanConstellationNodes to identify existing Accounts. - This is used by "feditest info --nodedriver" to help the user figure out how to specify - pre-existing Accounts on a Node. - """ - return [] # By default: cannot be done - - - @staticmethod - def test_plan_node_non_existing_account_fields() -> list[TestPlanNodeNonExistingAccountField]: - """ - Return the TestPlanNodeNonExistingAccountFields that may be specified on TestPlanConstellationNodes to identify non-existing Accounts. - This is used by "feditest info --nodedriver" to help the user figure out how to specify - non-existing Accounts on a Node. - """ - return [] # By default: cannot be done - - - def create_configuration_account_manager(self, rolename: str, test_plan_node: TestPlanConstellationNode) -> tuple[NodeConfiguration, AccountManager | None]: - """ - Read the node data provided in test_plan_node and create a NodeConfiguration object - from it. This will throw exceptions if the Node is misconfigured. - - May be overridden in subclasses. - """ - return ( - NodeConfiguration( - self, - test_plan_node.parameter_or_raise(APP_PAR), - test_plan_node.parameter(APP_VERSION_PAR), - test_plan_node.parameter(HOSTNAME_PAR) - ), - None - ) - - - @final - def provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None = None) -> Node: - """ - Instantiate a Node - rolename: the name of this Node in the constellation - config: the NodeConfiguration created with create_configuration - """ - info(f'Provisioning node for role "{ rolename }" with { config }.') - ret = self._provision_node(rolename, config, account_manager) - return ret - - - @final - def unprovision_node(self, node: Node) -> None: - """ - Deactivate and delete a Node - node: the Node - """ - if node.node_driver != self : - raise Exception(f"Node does not belong to this NodeDriver: { node.node_driver } vs { self }") # pylint: disable=broad-exception-raised - - info(f'Unprovisioning node for role "{ node.rolename }" with NodeDriver "{ self.__class__.__name__}".') - self._unprovision_node(node) - - - def _provision_node(self, rolename: str, config: NodeConfiguration, account_manager: AccountManager | None) -> Node: - """ - The factory method for Node. Any subclass of NodeDriver should also - override this and return a more specific subclass of IUT. - """ - raise NotImplementedByNodeDriverError(self, NodeDriver._provision_node) - - - def _unprovision_node(self, node: Node) -> None: - """ - Invoked when a Node gets unprovisioned, in case cleanup needs to be performed. - This is here so subclasses of NodeDriver can override it. - """ - # by default, do nothing - pass # pylint: disable=unnecessary-pass - - - def prompt_user(self, question: str, value_if_known: Any | None = None, parse_validate: Callable[[str],Any] | None = None) -> Any | None: - """ - If an NodeDriver does not natively implement support for a particular method, - this method is invoked as a fallback. It prompts the user to enter information - at the console. - - This is implemented on NodeDriver rather than Node, so we can also ask - provisioning-related questions. - - question: the text to be emitted to the user as a prompt - value_if_known: if given, that value can be used instead of asking the user - parse_validate: optional function that attempts to parse and validate the provided user input. - If the value is valid, it parses the value and returns the parsed version. If not valid, it returns None. - return: the value entered by the user, parsed, or None - """ - if value_if_known: - if parse_validate is None: - return value_if_known - ret_parsed = parse_validate(value_if_known) - if ret_parsed is not None: - return ret_parsed - warning(f'Preconfigured value "{ value_if_known }" is invalid, ignoring.') - - while True: - ret = input(f'TESTER ACTION REQUIRED: { question }') - if parse_validate is None: - return ret - ret_parsed = parse_validate(ret) - if ret_parsed is not None: - return ret_parsed - print(f'INPUT ERROR: invalid input, try again. Was: "{ ret }"') - - - def __str__(self) -> str: - return self.__class__.__name__ - - -class SkipTestException(Exception): - """ - Indicates that the test wanted to be skipped. It can be thrown if the test recognizes - the circumstances in which it should be run are not currently present. - Modeled after https://github.com/hamcrest/PyHamcrest/blob/main/src/hamcrest/core/assert_that.py - """ - def __init__(self, msg: str) : - """ - Provide reasoning why this test was skipped. - """ - super().__init__(msg) - - -class NotImplementedByNodeOrDriverError(SkipTestException): - pass - - -class NotImplementedByNodeError(NotImplementedByNodeOrDriverError): - """ - This exception is raised when a Node cannot perform a certain operation because it - has not been implemented in this subtype of Node. - """ - def __init__(self, node: Node, method: Callable[...,Any], arg: Any = None ): - super().__init__(f"Not implemented by node {node}: {method.__name__}" + (f" ({ arg })" if arg else "")) - - -class NotImplementedByNodeDriverError(NotImplementedByNodeOrDriverError): - """ - This exception is raised when a Node cannot perform a certain operation because it - has not been implemented in this subtype of Node. - """ - def __init__(self, node_driver: NodeDriver, method: Callable[...,Any], arg: Any = None ): - super().__init__(f"Not implemented by node driver {node_driver}: {method.__name__}" + (f" ({ arg })" if arg else "")) - - -class NodeOutOfAccountsException(RuntimeError): - """ - A test wanted to obtain an (or obtain another) account on this Node, but no account was - known, no account could be automatically provisioned, or all known or provisionable - accounts were returned already. - """ - def __init__(self, node: NodeDriver, rolename: str ): - super().__init__(f"Out of accounts on Node { node }, account role { rolename }" ) - - -class NodeSpecificationInsufficientError(RuntimeError): - """ - This exception is raised when a NodeDriver cannot instantiate a Node because insufficient - information (parameters) has been provided. - """ - def __init__(self, node_driver: NodeDriver, details: str ): - super().__init__(f"Node specification is insufficient for {node_driver}: {details}" ) - - -class NodeSpecificationInvalidError(RuntimeError): - """ - This exception is raised when a NodeDriver cannot instantiate a Node because invalid - information (e.g. a syntax error in a parameter) has been provided. - """ - def __init__(self, node_driver: NodeDriver, parameter: str, details: str ): - super().__init__(f"Node specification is invalid for {node_driver}, parameter {parameter}: {details}" ) - - -class TimeoutException(RuntimeError): - """ - A result has not arrived within the expected time period. - """ - def __init__(self, msg: str, timeout: float): - super().__init__(f'{ msg } (timeout: { timeout })') +""" \ No newline at end of file diff --git a/src/feditest/protocols/activitypub/__init__.py b/src/feditest/protocols/activitypub/__init__.py index beb77c1..e842868 100644 --- a/src/feditest/protocols/activitypub/__init__.py +++ b/src/feditest/protocols/activitypub/__init__.py @@ -6,10 +6,10 @@ import httpx from hamcrest import is_not -from feditest.protocols.activitypub.utils import is_member_of_collection_at from feditest import InteropLevel, SpecLevel, assert_that -from feditest.protocols import NotImplementedByNodeError +from feditest.nodedrivers import NotImplementedByNodeError +from feditest.protocols.activitypub.utils import is_member_of_collection_at from feditest.protocols.web import WebServer # Note: diff --git a/src/feditest/protocols/activitypub/utils.py b/src/feditest/protocols/activitypub/utils.py index 0350cd0..deb2194 100644 --- a/src/feditest/protocols/activitypub/utils.py +++ b/src/feditest/protocols/activitypub/utils.py @@ -7,7 +7,7 @@ from hamcrest.core.base_matcher import BaseMatcher from hamcrest.core.description import Description -from feditest.protocols import Node +from feditest.nodedrivers import Node from feditest.utils import boolean_response_parse_validate diff --git a/src/feditest/protocols/fediverse/__init__.py b/src/feditest/protocols/fediverse/__init__.py index c9ac2b3..5cf6865 100644 --- a/src/feditest/protocols/fediverse/__init__.py +++ b/src/feditest/protocols/fediverse/__init__.py @@ -2,7 +2,7 @@ Abstractions for nodes that speak today's Fediverse protocol stack. """ -from feditest.protocols import NotImplementedByNodeError +from feditest.nodedrivers import NotImplementedByNodeError from feditest.protocols.activitypub import ActivityPubNode from feditest.protocols.webfinger import WebFingerClient, WebFingerServer diff --git a/src/feditest/protocols/sandbox/__init__.py b/src/feditest/protocols/sandbox/__init__.py index fe2953c..ff94f23 100644 --- a/src/feditest/protocols/sandbox/__init__.py +++ b/src/feditest/protocols/sandbox/__init__.py @@ -5,7 +5,7 @@ from datetime import datetime, UTC from typing import List -from feditest.protocols import Node, NotImplementedByNodeError +from feditest.nodedrivers import Node, NotImplementedByNodeError class SandboxLogEvent: """ diff --git a/src/feditest/protocols/web/__init__.py b/src/feditest/protocols/web/__init__.py index 69feb5d..8fe3f2d 100644 --- a/src/feditest/protocols/web/__init__.py +++ b/src/feditest/protocols/web/__init__.py @@ -4,7 +4,7 @@ from datetime import UTC, date, datetime from typing import Any, Callable, List, final -from feditest.protocols import Node, NotImplementedByNodeError +from feditest.nodedrivers import Node, NotImplementedByNodeError from feditest.protocols.web.traffic import ( HttpRequest, HttpRequestResponsePair, diff --git a/src/feditest/protocols/webfinger/__init__.py b/src/feditest/protocols/webfinger/__init__.py index d597331..c4e1063 100644 --- a/src/feditest/protocols/webfinger/__init__.py +++ b/src/feditest/protocols/webfinger/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Callable from urllib.parse import quote, urlparse -from feditest.protocols import NotImplementedByNodeError +from feditest.nodedrivers import NotImplementedByNodeError from feditest.protocols.web import WebClient, WebServer from feditest.protocols.webfinger.traffic import WebFingerQueryResponse diff --git a/src/feditest/testrun.py b/src/feditest/testrun.py index ecfee05..aca7fa9 100644 --- a/src/feditest/testrun.py +++ b/src/feditest/testrun.py @@ -13,7 +13,7 @@ import feditest.testruncontroller import feditest.testruntranscript import feditest.tests -from feditest.protocols import AccountManager, Node, NodeConfiguration, NodeDriver +from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver from feditest.registry import registry_singleton from feditest.reporting import error, fatal, info, trace, warning from feditest.testplan import ( diff --git a/tests.unit/test_10_register_nodedrivers.py b/tests.unit/test_10_register_nodedrivers.py index ecfa82d..386aa17 100644 --- a/tests.unit/test_10_register_nodedrivers.py +++ b/tests.unit/test_10_register_nodedrivers.py @@ -6,7 +6,7 @@ import feditest from feditest import nodedriver -from feditest.protocols import NodeDriver +from feditest.nodedrivers import NodeDriver @pytest.fixture(scope="module", autouse=True) diff --git a/tests.unit/test_20_create_test_plan_session_template.py b/tests.unit/test_20_create_test_plan_session_template.py index 008e717..2ccf20d 100644 --- a/tests.unit/test_20_create_test_plan_session_template.py +++ b/tests.unit/test_20_create_test_plan_session_template.py @@ -7,7 +7,7 @@ import feditest from feditest import test -from feditest.protocols import Node +from feditest.nodedrivers import Node from feditest.testplan import TestPlanConstellation, TestPlanConstellationNode, TestPlanSession, TestPlanTestSpec diff --git a/tests.unit/test_30_create_testplan.py b/tests.unit/test_30_create_testplan.py index b35e183..5f06669 100644 --- a/tests.unit/test_30_create_testplan.py +++ b/tests.unit/test_30_create_testplan.py @@ -6,7 +6,7 @@ import feditest from feditest import test -from feditest.protocols import Node +from feditest.nodedrivers import Node from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSession, TestPlanTestSpec diff --git a/tests.unit/test_40_report_node_driver_errors.py b/tests.unit/test_40_report_node_driver_errors.py index 91a7845..eadc031 100644 --- a/tests.unit/test_40_report_node_driver_errors.py +++ b/tests.unit/test_40_report_node_driver_errors.py @@ -5,7 +5,7 @@ import feditest import pytest from feditest import nodedriver -from feditest.protocols import AccountManager, Node, NodeConfiguration, NodeDriver +from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver from feditest.testplan import ( TestPlan, TestPlanConstellation, diff --git a/tests.unit/test_40_static_account_manager_fallback_fediverse_accounts.py b/tests.unit/test_40_static_account_manager_fallback_fediverse_accounts.py index a67af98..f0c0114 100644 --- a/tests.unit/test_40_static_account_manager_fallback_fediverse_accounts.py +++ b/tests.unit/test_40_static_account_manager_fallback_fediverse_accounts.py @@ -8,8 +8,8 @@ import pytest import feditest +from feditest.nodedrivers import StaticAccountManager from feditest.nodedrivers.fallback.fediverse import FallbackFediverseAccount, FallbackFediverseNonExistingAccount -from feditest.protocols import StaticAccountManager HOSTNAME = 'localhost' diff --git a/tests.unit/test_50_run_not_implemented.py b/tests.unit/test_50_run_not_implemented.py index 9248302..2ea88d6 100644 --- a/tests.unit/test_50_run_not_implemented.py +++ b/tests.unit/test_50_run_not_implemented.py @@ -5,7 +5,7 @@ import pytest import feditest -from feditest.protocols import AccountManager, Node, NodeConfiguration, NodeDriver, NotImplementedByNodeError +from feditest.nodedrivers import AccountManager, Node, NodeConfiguration, NodeDriver, NotImplementedByNodeError from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSession, TestPlanTestSpec from feditest.testrun import TestRun from feditest.testruncontroller import AutomaticTestRunController diff --git a/tests.unit/test_50_run_skip.py b/tests.unit/test_50_run_skip.py index 2306d89..de04fac 100644 --- a/tests.unit/test_50_run_skip.py +++ b/tests.unit/test_50_run_skip.py @@ -5,7 +5,7 @@ import pytest import feditest -from feditest.protocols import SkipTestException +from feditest.nodedrivers import SkipTestException from feditest.testplan import TestPlan, TestPlanConstellation, TestPlanSession, TestPlanTestSpec from feditest.testrun import TestRun from feditest.testruncontroller import AutomaticTestRunController