Tests added.
Some bug solved.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -37,6 +37,13 @@ docs/_build/
|
|||||||
# MkDocs generated site
|
# MkDocs generated site
|
||||||
site/
|
site/
|
||||||
|
|
||||||
|
# pytest
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -1,5 +1,49 @@
|
|||||||
# pyapp-engine
|
# pyapp-engine
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev,docs]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage report is displayed automatically in the terminal. Other formats:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov-report=html # htmlcov/index.html (browsable)
|
||||||
|
pytest --cov-report=xml # coverage.xml (CI tools)
|
||||||
|
pytest --cov-report=term # terminal summary only
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports can be combined:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov-report=term-missing --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Preview locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdocs serve
|
||||||
|
# available at http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the static site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdocs build
|
||||||
|
# output in site/
|
||||||
|
```
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
The pyapp-engine module was written by François Dausseur fdausseur@free.fr.
|
The pyapp-engine module was written by François Dausseur fdausseur@free.fr.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
site_name: pyappengine
|
site_name: pyappengine
|
||||||
site_description: Python Application Engine — modular command-based application framework
|
site_description: Python Application Engine — modular command-based application framework
|
||||||
site_author: François Dausseur
|
site_author: François Dausseur
|
||||||
repo_url: https://git.beafrancois.fr/Foue/pyappengine
|
repo_url: https://git.beafrancois.fr/Foue-opensource/pyappengine
|
||||||
repo_name: pyappengine
|
repo_name: pyappengine
|
||||||
|
|
||||||
theme:
|
theme:
|
||||||
|
|||||||
@@ -24,9 +24,18 @@ docs = [
|
|||||||
"mkdocs-material>=9.0",
|
"mkdocs-material>=9.0",
|
||||||
"mkdocstrings[python]>=0.24",
|
"mkdocstrings[python]>=0.24",
|
||||||
]
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src", "tests"]
|
||||||
|
addopts = "--cov=appengine --cov-report=term-missing"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Homepage" = "https://git.beafrancois.fr/Foue/pyappengine"
|
"Homepage" = "https://git.beafrancois.fr/Foue-opensource/pyappengine"
|
||||||
|
|
||||||
[tool.setuptools.dynamic]
|
[tool.setuptools.dynamic]
|
||||||
version = {file = ["VERSION"]}
|
version = {file = ["VERSION"]}
|
||||||
@@ -64,7 +64,7 @@ class AEErrs(Enum):
|
|||||||
INTERNAL_ERROR = -32000
|
INTERNAL_ERROR = -32000
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return ERROR_MESSAGES[self.value]
|
return ERROR_MESSAGES[self]
|
||||||
|
|
||||||
ERROR_MESSAGES = {
|
ERROR_MESSAGES = {
|
||||||
AEErrs.PARSE_ERROR: """Invalid JSON was received by the server.
|
AEErrs.PARSE_ERROR: """Invalid JSON was received by the server.
|
||||||
@@ -385,6 +385,8 @@ class Commands(Thread):
|
|||||||
# help <module>
|
# help <module>
|
||||||
if len(spl) == 1:
|
if len(spl) == 1:
|
||||||
module = arg
|
module = arg
|
||||||
|
args = []
|
||||||
|
kwargs = {}
|
||||||
# help <module>.<method>
|
# help <module>.<method>
|
||||||
else:
|
else:
|
||||||
module = spl[0]
|
module = spl[0]
|
||||||
@@ -399,6 +401,7 @@ class Commands(Thread):
|
|||||||
ret = 'module "{}" not found'.format(module)
|
ret = 'module "{}" not found'.format(module)
|
||||||
self.log.error(ret)
|
self.log.error(ret)
|
||||||
return success, (AEErrs.INVALID_REQUEST.value, ret)
|
return success, (AEErrs.INVALID_REQUEST.value, ret)
|
||||||
|
module = m
|
||||||
|
|
||||||
return self.cmods[module]._execute_command(method, *args, **kwargs)
|
return self.cmods[module]._execute_command(method, *args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
53
tests/conftest.py
Normal file
53
tests/conftest.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import pytest
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from threading import Lock, Event
|
||||||
|
|
||||||
|
from appengine import Commands, AppEngineException, AEErrs
|
||||||
|
|
||||||
|
|
||||||
|
class SampleModule(Commands):
|
||||||
|
"""Concrete Commands subclass used across tests."""
|
||||||
|
|
||||||
|
def cmd_add(self, a, b):
|
||||||
|
"""Add two numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a: First number.
|
||||||
|
b: Second number.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Sum of a and b.
|
||||||
|
"""
|
||||||
|
return float(a) + float(b)
|
||||||
|
|
||||||
|
def cmd_raise_ae(self):
|
||||||
|
"""Raise an AppEngineException."""
|
||||||
|
raise AppEngineException(AEErrs.INVALID_PARAMS, "test error")
|
||||||
|
|
||||||
|
def cmd_raise_generic(self):
|
||||||
|
"""Raise a generic exception."""
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log():
|
||||||
|
return logging.getLogger("test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_module(log):
|
||||||
|
m = SampleModule(None, log)
|
||||||
|
m.lock = Lock()
|
||||||
|
m.cmods = {}
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_imported_modules():
|
||||||
|
"""Remove dynamically imported cmds_* modules after each test."""
|
||||||
|
yield
|
||||||
|
to_remove = [k for k in sys.modules if k.startswith("cmds_")]
|
||||||
|
for k in to_remove:
|
||||||
|
del sys.modules[k]
|
||||||
72
tests/test_app_engine.py
Normal file
72
tests/test_app_engine.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import logging
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from appengine import AppEngine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
ae = AppEngine("test_app")
|
||||||
|
ae.cl = Mock() # prevent NoneType error in wait_stop before exec() is called
|
||||||
|
yield ae
|
||||||
|
ae.stop()
|
||||||
|
ae.stop_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInit:
|
||||||
|
def test_creates_logger(self, app):
|
||||||
|
assert isinstance(app.log, logging.Logger)
|
||||||
|
|
||||||
|
def test_log_level_warning_by_default(self, app):
|
||||||
|
assert app.log.level == logging.WARNING
|
||||||
|
|
||||||
|
def test_log_level_debug_when_requested(self):
|
||||||
|
ae = AppEngine("test_debug", debug=True)
|
||||||
|
ae.cl = Mock()
|
||||||
|
assert ae.log.level == logging.DEBUG
|
||||||
|
ae.stop()
|
||||||
|
ae.stop_thread.join(timeout=2)
|
||||||
|
|
||||||
|
def test_stop_thread_is_alive(self, app):
|
||||||
|
assert app.stop_thread.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseConfig:
|
||||||
|
def test_valid_config_file(self, tmp_path):
|
||||||
|
cf = tmp_path / "config.ini"
|
||||||
|
cf.write_text("[general]\ndefault = mymod\n")
|
||||||
|
ae = AppEngine("test_cfg")
|
||||||
|
ae.cl = Mock()
|
||||||
|
ae.parse_config(str(cf))
|
||||||
|
assert ae.conf["general"]["default"] == "mymod"
|
||||||
|
ae.stop()
|
||||||
|
ae.stop_thread.join(timeout=2)
|
||||||
|
|
||||||
|
def test_missing_config_raises(self, app):
|
||||||
|
with pytest.raises(Exception, match="not found"):
|
||||||
|
app.parse_config("/nonexistent/config.ini")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefLog:
|
||||||
|
def test_valid_log_file_adds_file_handler(self, tmp_path):
|
||||||
|
log_file = str(tmp_path / "test.log")
|
||||||
|
ae = AppEngine("test_logfile", log_file=log_file)
|
||||||
|
ae.cl = Mock()
|
||||||
|
assert any(isinstance(h, logging.FileHandler) for h in ae.log.handlers)
|
||||||
|
ae.stop()
|
||||||
|
ae.stop_thread.join(timeout=2)
|
||||||
|
for h in ae.log.handlers:
|
||||||
|
h.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStop:
|
||||||
|
def test_stop_sets_event(self, app):
|
||||||
|
assert not app.stop_event.is_set()
|
||||||
|
app.stop()
|
||||||
|
assert app.stop_event.is_set()
|
||||||
|
|
||||||
|
def test_stop_thread_exits_after_stop(self, app):
|
||||||
|
app.stop()
|
||||||
|
app.stop_thread.join(timeout=2)
|
||||||
|
assert not app.stop_thread.is_alive()
|
||||||
174
tests/test_commands.py
Normal file
174
tests/test_commands.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import logging
|
||||||
|
import pytest
|
||||||
|
from threading import Event, Lock
|
||||||
|
|
||||||
|
from appengine import Commands, AEErrs, AppEngineException
|
||||||
|
from conftest import SampleModule
|
||||||
|
|
||||||
|
|
||||||
|
class TestValToPrint:
|
||||||
|
def test_string_passthrough(self, sample_module):
|
||||||
|
assert sample_module.val_to_print("hello") == "hello"
|
||||||
|
|
||||||
|
def test_float_default_one_decimal(self, sample_module):
|
||||||
|
assert sample_module.val_to_print(3.14159) == "3.1"
|
||||||
|
|
||||||
|
def test_float_custom_decimals(self, sample_module):
|
||||||
|
assert sample_module.val_to_print(3.14159, dig=3) == "3.142"
|
||||||
|
|
||||||
|
def test_integer_as_string(self, sample_module):
|
||||||
|
assert sample_module.val_to_print(42) == "42"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogging:
|
||||||
|
def _bare_module(self):
|
||||||
|
m = SampleModule(None, None)
|
||||||
|
m.lock = Lock()
|
||||||
|
return m
|
||||||
|
|
||||||
|
def test_info_falls_back_to_print(self, capsys):
|
||||||
|
self._bare_module().info("hello")
|
||||||
|
assert "hello" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_debug_falls_back_to_print(self, capsys):
|
||||||
|
self._bare_module().debug("dbg")
|
||||||
|
assert "dbg" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_warning_falls_back_to_print(self, capsys):
|
||||||
|
self._bare_module().warning("warn")
|
||||||
|
assert "warn" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_error_falls_back_to_print(self, capsys):
|
||||||
|
self._bare_module().error("err")
|
||||||
|
assert "err" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_info_uses_logger(self, sample_module, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger="test"):
|
||||||
|
sample_module.info("via logger")
|
||||||
|
assert "via logger" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestStop:
|
||||||
|
def test_initial_state(self, sample_module):
|
||||||
|
assert sample_module.stopped is False
|
||||||
|
|
||||||
|
def test_stop_sets_flag(self, sample_module):
|
||||||
|
sample_module.stop()
|
||||||
|
assert sample_module.stopped is True
|
||||||
|
|
||||||
|
def test_stop_all_without_event(self, sample_module):
|
||||||
|
sample_module.stop_all_event = None
|
||||||
|
sample_module.stop_all() # must not raise
|
||||||
|
|
||||||
|
def test_stop_all_sets_event(self, sample_module):
|
||||||
|
event = Event()
|
||||||
|
sample_module.stop_all_event = event
|
||||||
|
sample_module.stop_all()
|
||||||
|
assert event.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFree:
|
||||||
|
def test_free_is_noop(self, sample_module):
|
||||||
|
sample_module.free() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdHelp:
|
||||||
|
def test_no_args_lists_commands(self, sample_module):
|
||||||
|
result = sample_module.cmd_help()
|
||||||
|
assert "add" in result
|
||||||
|
assert "raise_ae" in result
|
||||||
|
assert "raise_generic" in result
|
||||||
|
|
||||||
|
def test_known_command_returns_docstring(self, sample_module):
|
||||||
|
result = sample_module.cmd_help("add")
|
||||||
|
assert "Add two numbers" in result
|
||||||
|
|
||||||
|
def test_undocumented_command(self, log):
|
||||||
|
class NoDoc(Commands):
|
||||||
|
def cmd_nodoc(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
m = NoDoc(None, log)
|
||||||
|
m.lock = Lock()
|
||||||
|
assert "No documentation" in m.cmd_help("nodoc")
|
||||||
|
|
||||||
|
def test_unknown_command(self, sample_module):
|
||||||
|
assert "No command with this name" in sample_module.cmd_help("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
|
class TestListModules:
|
||||||
|
def test_empty_cmods(self, sample_module):
|
||||||
|
sample_module.cmods = {}
|
||||||
|
success, result = sample_module.list_modules()
|
||||||
|
assert success is True
|
||||||
|
assert "List of modules" in result
|
||||||
|
|
||||||
|
def test_with_modules(self, sample_module):
|
||||||
|
sample_module.cmods = {"foo": None, "bar": None}
|
||||||
|
_, result = sample_module.list_modules()
|
||||||
|
assert "foo" in result
|
||||||
|
assert "bar" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteCommand:
|
||||||
|
def test_success(self, sample_module):
|
||||||
|
success, result = sample_module._execute_command("add", 1, 2)
|
||||||
|
assert success is True
|
||||||
|
assert result == 3.0
|
||||||
|
|
||||||
|
def test_method_not_found(self, sample_module):
|
||||||
|
success, _ = sample_module._execute_command("nonexistent")
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
def test_wrong_params(self, sample_module):
|
||||||
|
success, result = sample_module._execute_command("add") # missing a, b
|
||||||
|
assert success is False
|
||||||
|
assert result[0] == AEErrs.INVALID_PARAMS.value
|
||||||
|
|
||||||
|
def test_app_engine_exception(self, sample_module):
|
||||||
|
success, result = sample_module._execute_command("raise_ae")
|
||||||
|
assert success is False
|
||||||
|
assert result[0] == AEErrs.INVALID_PARAMS.value
|
||||||
|
assert result[1] == "test error"
|
||||||
|
|
||||||
|
def test_generic_exception(self, sample_module):
|
||||||
|
success, result = sample_module._execute_command("raise_generic")
|
||||||
|
assert success is False
|
||||||
|
assert result[0] == AEErrs.INTERNAL_ERROR.value
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatch:
|
||||||
|
def test_module_not_found(self, sample_module):
|
||||||
|
success, result = sample_module.execute_command("nonexistent", "add", 1, 2)
|
||||||
|
assert success is False
|
||||||
|
assert result[0] == AEErrs.INVALID_REQUEST.value
|
||||||
|
|
||||||
|
def test_underscore_hyphen_alias(self, sample_module):
|
||||||
|
sample_module.cmods = {"my-mod": sample_module}
|
||||||
|
success, _ = sample_module.execute_command("my_mod", "add", 1, 2)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
def test_help_no_args_lists_modules(self, sample_module):
|
||||||
|
sample_module.cmods = {"sample": sample_module}
|
||||||
|
success, result = sample_module.execute_command("", "help")
|
||||||
|
assert success is True
|
||||||
|
assert "sample" in result
|
||||||
|
|
||||||
|
def test_help_with_module(self, sample_module):
|
||||||
|
sample_module.cmods = {"sample": sample_module}
|
||||||
|
success, result = sample_module.execute_command("", "help", "sample")
|
||||||
|
assert success is True
|
||||||
|
assert "add" in result
|
||||||
|
|
||||||
|
def test_help_module_dot_function(self, sample_module):
|
||||||
|
sample_module.cmods = {"sample": sample_module}
|
||||||
|
success, result = sample_module.execute_command("", "help", "sample.add")
|
||||||
|
assert success is True
|
||||||
|
assert "Add two numbers" in result
|
||||||
|
|
||||||
|
def test_no_module_uses_defmod(self, sample_module):
|
||||||
|
Commands.defmod = "sample"
|
||||||
|
sample_module.cmods = {"sample": sample_module}
|
||||||
|
success, result = sample_module.execute_command("", "add", 1, 2)
|
||||||
|
assert success is True
|
||||||
|
assert result == 3.0
|
||||||
179
tests/test_commands_loader.py
Normal file
179
tests/test_commands_loader.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from threading import Event
|
||||||
|
|
||||||
|
from appengine import CommandsLoader
|
||||||
|
|
||||||
|
|
||||||
|
SIMPLE_MODULE = """\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class {classname}(Commands):
|
||||||
|
def cmd_hello(self):
|
||||||
|
\"\"\"Say hello.\"\"\"
|
||||||
|
return "hello"
|
||||||
|
"""
|
||||||
|
|
||||||
|
THREADED_MODULE = """\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class {classname}(Commands):
|
||||||
|
def __init__(self, config, log):
|
||||||
|
super().__init__(config, log)
|
||||||
|
self.threaded = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while not self.stopped:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cmd_ping(self):
|
||||||
|
return "pong"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log():
|
||||||
|
return logging.getLogger("test_loader")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def basic_config():
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def make_loader(tmp_path, config, log):
|
||||||
|
stop_event = Event()
|
||||||
|
loader = CommandsLoader(stop_event, config, log, str(tmp_path))
|
||||||
|
return loader, stop_event
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadCommands:
|
||||||
|
def test_loads_matching_file(self, tmp_path, basic_config, log):
|
||||||
|
(tmp_path / "cmds_hello.py").write_text(SIMPLE_MODULE.format(classname="Hello"))
|
||||||
|
loader, _ = make_loader(tmp_path, basic_config, log)
|
||||||
|
assert "hello" in loader.cmods
|
||||||
|
|
||||||
|
def test_ignores_non_prefixed_files(self, tmp_path, basic_config, log):
|
||||||
|
(tmp_path / "not_a_module.py").write_text(SIMPLE_MODULE.format(classname="NotA"))
|
||||||
|
loader, _ = make_loader(tmp_path, basic_config, log)
|
||||||
|
assert len(loader.cmods) == 0
|
||||||
|
|
||||||
|
def test_nickname_overrides_filename(self, tmp_path, log):
|
||||||
|
(tmp_path / "cmds_mymod.py").write_text("""\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class MyMod(Commands):
|
||||||
|
def __init__(self, config, log):
|
||||||
|
super().__init__(config, log)
|
||||||
|
self.nickname = "custom"
|
||||||
|
""")
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
loader, _ = make_loader(tmp_path, config, log)
|
||||||
|
assert "custom" in loader.cmods
|
||||||
|
|
||||||
|
def test_loads_multiple_modules(self, tmp_path, basic_config, log):
|
||||||
|
(tmp_path / "cmds_alpha.py").write_text(SIMPLE_MODULE.format(classname="Alpha"))
|
||||||
|
(tmp_path / "cmds_beta.py").write_text(SIMPLE_MODULE.format(classname="Beta"))
|
||||||
|
loader, _ = make_loader(tmp_path, basic_config, log)
|
||||||
|
assert "alpha" in loader.cmods
|
||||||
|
assert "beta" in loader.cmods
|
||||||
|
|
||||||
|
def test_module_config_section_passed(self, tmp_path, log):
|
||||||
|
(tmp_path / "cmds_configured.py").write_text("""\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class Configured(Commands):
|
||||||
|
def cmd_get_setting(self):
|
||||||
|
return self.config.get("my_key")
|
||||||
|
""")
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
config["configured"] = {"my_key": "my_value"}
|
||||||
|
loader, _ = make_loader(tmp_path, config, log)
|
||||||
|
assert loader.cmods["configured"].config["my_key"] == "my_value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetCmods:
|
||||||
|
def test_cmods_shared_across_all_modules(self, tmp_path, basic_config, log):
|
||||||
|
(tmp_path / "cmds_a.py").write_text(SIMPLE_MODULE.format(classname="A"))
|
||||||
|
(tmp_path / "cmds_b.py").write_text(SIMPLE_MODULE.format(classname="B"))
|
||||||
|
loader, _ = make_loader(tmp_path, basic_config, log)
|
||||||
|
for v in loader.cmods.values():
|
||||||
|
assert set(v.cmods.keys()) == {"a", "b"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadDependencies:
|
||||||
|
def test_list_dependency_injected(self, tmp_path, log):
|
||||||
|
(tmp_path / "cmds_provider.py").write_text(SIMPLE_MODULE.format(classname="Provider"))
|
||||||
|
(tmp_path / "cmds_consumer.py").write_text("""\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class Consumer(Commands):
|
||||||
|
dependencies = ["provider"]
|
||||||
|
|
||||||
|
def cmd_test(self):
|
||||||
|
return "ok"
|
||||||
|
""")
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
loader, _ = make_loader(tmp_path, config, log)
|
||||||
|
loader._load_dependencies()
|
||||||
|
assert hasattr(loader.cmods["consumer"], "provider")
|
||||||
|
assert loader.cmods["consumer"].provider is loader.cmods["provider"]
|
||||||
|
|
||||||
|
def test_dict_dependency_injected(self, tmp_path, log):
|
||||||
|
(tmp_path / "cmds_svc.py").write_text(SIMPLE_MODULE.format(classname="Svc"))
|
||||||
|
(tmp_path / "cmds_client.py").write_text("""\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class Client(Commands):
|
||||||
|
dependencies = {"my_svc": "svc"}
|
||||||
|
|
||||||
|
def cmd_test(self):
|
||||||
|
return "ok"
|
||||||
|
""")
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
loader, _ = make_loader(tmp_path, config, log)
|
||||||
|
loader._load_dependencies()
|
||||||
|
assert hasattr(loader.cmods["client"], "my_svc")
|
||||||
|
assert loader.cmods["client"].my_svc is loader.cmods["svc"]
|
||||||
|
|
||||||
|
def test_missing_dependency_logs_error(self, tmp_path, log, caplog):
|
||||||
|
(tmp_path / "cmds_orphan.py").write_text("""\
|
||||||
|
from appengine import Commands
|
||||||
|
|
||||||
|
class Orphan(Commands):
|
||||||
|
dependencies = ["nonexistent"]
|
||||||
|
|
||||||
|
def cmd_test(self):
|
||||||
|
return "ok"
|
||||||
|
""")
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
loader, _ = make_loader(tmp_path, config, log)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="test_loader"):
|
||||||
|
loader._load_dependencies()
|
||||||
|
assert "nonexistent" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
def test_start_and_stop_threaded_module(self, tmp_path, log):
|
||||||
|
(tmp_path / "cmds_worker.py").write_text(THREADED_MODULE.format(classname="Worker"))
|
||||||
|
config = ConfigParser()
|
||||||
|
config["general"] = {}
|
||||||
|
loader, stop_event = make_loader(tmp_path, config, log)
|
||||||
|
loader.start()
|
||||||
|
assert loader.cmods["worker"].is_alive()
|
||||||
|
loader.stop()
|
||||||
|
loader.join()
|
||||||
|
assert not loader.cmods["worker"].is_alive()
|
||||||
|
|
||||||
|
def test_free_does_not_raise(self, tmp_path, basic_config, log):
|
||||||
|
(tmp_path / "cmds_simple.py").write_text(SIMPLE_MODULE.format(classname="Simple"))
|
||||||
|
loader, _ = make_loader(tmp_path, basic_config, log)
|
||||||
|
loader.free() # must not raise
|
||||||
62
tests/test_errors.py
Normal file
62
tests/test_errors.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import pytest
|
||||||
|
from appengine import AEErrs, AppEngineException, is_number
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsNumber:
|
||||||
|
def test_integer_string(self):
|
||||||
|
assert is_number("42") is True
|
||||||
|
|
||||||
|
def test_float_string(self):
|
||||||
|
assert is_number("3.14") is True
|
||||||
|
|
||||||
|
def test_negative_string(self):
|
||||||
|
assert is_number("-1.5") is True
|
||||||
|
|
||||||
|
def test_non_numeric_string(self):
|
||||||
|
assert is_number("hello") is False
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert is_number("") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAEErrs:
|
||||||
|
def test_parse_error_value(self):
|
||||||
|
assert AEErrs.PARSE_ERROR.value == -32700
|
||||||
|
|
||||||
|
def test_invalid_request_value(self):
|
||||||
|
assert AEErrs.INVALID_REQUEST.value == -32600
|
||||||
|
|
||||||
|
def test_meth_not_found_value(self):
|
||||||
|
assert AEErrs.METH_NOT_FOUND.value == -32601
|
||||||
|
|
||||||
|
def test_invalid_params_value(self):
|
||||||
|
assert AEErrs.INVALID_PARAMS.value == -32602
|
||||||
|
|
||||||
|
def test_internal_error_value(self):
|
||||||
|
assert AEErrs.INTERNAL_ERROR.value == -32000
|
||||||
|
|
||||||
|
def test_str_parse_error(self):
|
||||||
|
assert "Invalid JSON" in str(AEErrs.PARSE_ERROR)
|
||||||
|
|
||||||
|
def test_str_invalid_request(self):
|
||||||
|
assert "not a valid object" in str(AEErrs.INVALID_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppEngineException:
|
||||||
|
def test_default_message(self):
|
||||||
|
exc = AppEngineException(AEErrs.INTERNAL_ERROR)
|
||||||
|
assert exc.value == AEErrs.INTERNAL_ERROR.value
|
||||||
|
assert "Internal error" in exc.mesg
|
||||||
|
|
||||||
|
def test_custom_message(self):
|
||||||
|
exc = AppEngineException(AEErrs.INVALID_PARAMS, "bad param")
|
||||||
|
assert exc.value == AEErrs.INVALID_PARAMS.value
|
||||||
|
assert exc.mesg == "bad param"
|
||||||
|
|
||||||
|
def test_inherits_exception(self):
|
||||||
|
assert isinstance(AppEngineException(AEErrs.INTERNAL_ERROR), Exception)
|
||||||
|
|
||||||
|
def test_raise_and_catch(self):
|
||||||
|
with pytest.raises(AppEngineException) as exc_info:
|
||||||
|
raise AppEngineException(AEErrs.METH_NOT_FOUND, "not found")
|
||||||
|
assert exc_info.value.value == AEErrs.METH_NOT_FOUND.value
|
||||||
Reference in New Issue
Block a user