Tests added.

Some bug solved.
This commit is contained in:
2026-04-12 19:04:20 +02:00
parent 779d8cee44
commit b2220b5bbd
11 changed files with 607 additions and 4 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -1 +1 @@
0.6 0.7

View File

@@ -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:

View File

@@ -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"]}

View File

@@ -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
View 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
View 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
View 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

View 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
View 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