From b2220b5bbdd53a5bf94c3491cdcd6db6f56efec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sun, 12 Apr 2026 19:04:20 +0200 Subject: [PATCH] Tests added. Some bug solved. --- .gitignore | 7 ++ README.md | 44 +++++++++ VERSION | 2 +- mkdocs.yml | 2 +- pyproject.toml | 11 ++- src/appengine/__init__.py | 5 +- tests/conftest.py | 53 ++++++++++ tests/test_app_engine.py | 72 ++++++++++++++ tests/test_commands.py | 174 +++++++++++++++++++++++++++++++++ tests/test_commands_loader.py | 179 ++++++++++++++++++++++++++++++++++ tests/test_errors.py | 62 ++++++++++++ 11 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_app_engine.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_commands_loader.py create mode 100644 tests/test_errors.py diff --git a/.gitignore b/.gitignore index b5cca65..5a34e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,13 @@ docs/_build/ # MkDocs generated site site/ +# pytest +.pytest_cache/ + +# Coverage +.coverage +htmlcov/ + # Environments .env .venv diff --git a/README.md b/README.md index 8046633..79f136e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,49 @@ # 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 The pyapp-engine module was written by François Dausseur fdausseur@free.fr. diff --git a/VERSION b/VERSION index 490f510..0e2c939 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6 \ No newline at end of file +0.7 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index acd21be..eb0b3ef 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: pyappengine site_description: Python Application Engine — modular command-based application framework 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 theme: diff --git a/pyproject.toml b/pyproject.toml index b5405f0..18168dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,18 @@ docs = [ "mkdocs-material>=9.0", "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] -"Homepage" = "https://git.beafrancois.fr/Foue/pyappengine" +"Homepage" = "https://git.beafrancois.fr/Foue-opensource/pyappengine" [tool.setuptools.dynamic] version = {file = ["VERSION"]} \ No newline at end of file diff --git a/src/appengine/__init__.py b/src/appengine/__init__.py index 4439798..d5bb8bc 100644 --- a/src/appengine/__init__.py +++ b/src/appengine/__init__.py @@ -64,7 +64,7 @@ class AEErrs(Enum): INTERNAL_ERROR = -32000 def __str__(self) -> str: - return ERROR_MESSAGES[self.value] + return ERROR_MESSAGES[self] ERROR_MESSAGES = { AEErrs.PARSE_ERROR: """Invalid JSON was received by the server. @@ -385,6 +385,8 @@ class Commands(Thread): # help if len(spl) == 1: module = arg + args = [] + kwargs = {} # help . else: module = spl[0] @@ -399,6 +401,7 @@ class Commands(Thread): ret = 'module "{}" not found'.format(module) self.log.error(ret) return success, (AEErrs.INVALID_REQUEST.value, ret) + module = m return self.cmods[module]._execute_command(method, *args, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e9c13d8 --- /dev/null +++ b/tests/conftest.py @@ -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] diff --git a/tests/test_app_engine.py b/tests/test_app_engine.py new file mode 100644 index 0000000..e629b63 --- /dev/null +++ b/tests/test_app_engine.py @@ -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() diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..8b7be75 --- /dev/null +++ b/tests/test_commands.py @@ -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 diff --git a/tests/test_commands_loader.py b/tests/test_commands_loader.py new file mode 100644 index 0000000..a9e3647 --- /dev/null +++ b/tests/test_commands_loader.py @@ -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 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..4cb043b --- /dev/null +++ b/tests/test_errors.py @@ -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