14 Commits
v0.3 ... main

Author SHA1 Message Date
6938b5c2b2 Release note added. 2026-04-12 19:06:07 +02:00
b2220b5bbd Tests added.
Some bug solved.
2026-04-12 19:04:20 +02:00
779d8cee44 gitignore completed 2026-04-12 12:04:55 +02:00
5d8865a9fa Documentation added 2026-04-12 12:03:17 +02:00
François Dausseur
5a60e47c12 rev 0.6 2025-10-28 11:48:17 +01:00
François Dausseur
355915e9c1 Merge branch 'main' of https://git.beafrancois.fr/Foue-opensource/pyappengine 2025-10-28 11:37:14 +01:00
François Dausseur
0da36439a6 management of help functions improved. 2025-10-28 11:37:10 +01:00
François Dausseur
3d402db2c2 free not limited to threaded modules. modified the modules dependencies management. 2025-09-20 16:33:21 +02:00
François Dausseur
de04b2b3b9 robustness. 2025-04-30 17:39:35 +02:00
François Dausseur
fffba77497 Merge branch 'main' of https://git.beafrancois.fr/Foue-opensource/pyappengine 2025-04-17 14:40:38 +02:00
François Dausseur
28057dddd6 Removed systemd dependency 2025-04-17 14:40:34 +02:00
François Dausseur
d46e3b9859 release 0.4 2025-02-25 12:41:28 +01:00
François Dausseur
c67c5e3e28 allow to stop the application from a module 2025-02-25 12:39:34 +01:00
François Dausseur
a45d975617 new release 2025-02-25 11:48:24 +01:00
15 changed files with 1339 additions and 28 deletions

10
.gitignore vendored
View File

@@ -34,6 +34,16 @@ MANIFEST
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# MkDocs generated 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.2 0.7

25
docs/api.md Normal file
View File

@@ -0,0 +1,25 @@
# API Reference
## AppEngine
::: appengine.AppEngine
---
## Commands
::: appengine.Commands
---
## CommandsLoader
::: appengine.CommandsLoader
---
## Errors
::: appengine.AEErrs
::: appengine.AppEngineException

103
docs/getting-started.md Normal file
View File

@@ -0,0 +1,103 @@
# Getting started
## Project structure
```
my_project/
├── main.py
├── config.ini
└── modules/
├── cmds_foo.py
└── cmds_bar.py
```
## Configuration file
```ini
[general]
default = foo # default module (used when no module is specified)
# methods_prefix = cmd_ # optional: override command method prefix
# modules_prefix = cmds_ # optional: override module file prefix
[foo]
alias = f # optional: short name for the module
my_setting = value # any key accessible via self.config.get("my_setting")
[bar]
# ...
```
## Writing a module
```python
from appengine import Commands, AppEngineException, AEErrs
class Foo(Commands):
# Set to True to run this module in a background thread
threaded = False
# Declare dependencies on other modules
# dependencies = ["bar"] # list form → self.bar = cmods["bar"]
# dependencies = {"b": "bar"} # dict form → self.b = cmods["bar"]
def cmd_add(self, a: float, b: float):
"""Add two numbers.
Args:
a: First operand.
b: Second operand.
Returns:
float: The sum of *a* and *b*.
"""
return float(a) + float(b)
def cmd_fail(self):
"""Demonstrate structured error reporting."""
raise AppEngineException(AEErrs.INVALID_PARAMS, "Nothing to do here")
def free(self):
# Close connections, files, etc.
pass
```
## Threaded module
```python
import time
from appengine import Commands
class Worker(Commands):
threaded = True # will be started as a Thread
def run(self):
while not self.stopped:
self.info("tick")
time.sleep(1)
def free(self):
self.info("worker cleaned up")
```
## Dispatching commands
Commands are dispatched via `execute_command(module, method, *args)`:
```python
success, result = my_commands_instance.execute_command("foo", "add", 1, 2)
```
### Built-in help
| Call | Result |
|------|--------|
| `execute_command("", "help")` | List of all modules |
| `execute_command("", "help", "foo")` | List of commands in `foo` |
| `execute_command("", "help", "foo.add")` | Docstring of `foo.add` |
## Error handling
Commands return `(success: bool, result)`.
On error, `result` is a tuple `(error_code: int, message: str)`.
Error codes follow the JSON-RPC convention (see `AEErrs`).

65
docs/index.md Normal file
View File

@@ -0,0 +1,65 @@
# pyappengine
A Python framework for building **modular, command-based applications** with dynamic module loading, thread-safe command dispatch, and structured lifecycle management.
## Features
- **Dynamic module loading** — drop a `cmds_*.py` file in your module directory, it's loaded automatically
- **Thread-safe command dispatch** — shared lock across all modules
- **Threaded modules** — opt-in background thread execution per module
- **Inter-module dependencies** — declarative dependency injection between modules
- **Built-in help system** — introspects docstrings at runtime
- **INI configuration** — per-module config sections
- **Graceful shutdown** — SIGINT handler + global stop event
## Installation
```bash
pip install pyappengine
```
## Quick start
**1. Create your application entry point:**
```python
from appengine import AppEngine
app = AppEngine(
"my_app",
conf_file="config.ini",
log_file="my_app.log",
debug=True,
)
app.exec(modpath="./modules")
```
**2. Create a module** — save as `modules/cmds_hello.py`:
```python
from appengine import Commands
class Hello(Commands):
def cmd_greet(self, name: str):
"""Greet someone.
Args:
name: The person to greet.
Returns:
str: A greeting string.
"""
return f"Hello, {name}!"
```
**3. Create a minimal config**`config.ini`:
```ini
[general]
default = hello
```
## License
Released under the [CeCILL-C](https://cecill.info/licences/Licence_CeCILL-C_V1-en.html) license.
Author: François Dausseur — fdausseur@free.fr

51
mkdocs.yml Normal file
View File

@@ -0,0 +1,51 @@
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-opensource/pyappengine
repo_name: pyappengine
theme:
name: material
palette:
- scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- navigation.sections
- navigation.top
- search.suggest
- content.code.copy
plugins:
- search
- mkdocstrings:
handlers:
python:
options:
show_source: true
show_root_heading: true
docstring_style: google
members_order: source
nav:
- Home: index.md
- Getting started: getting-started.md
- API Reference: api.md
markdown_extensions:
- admonition
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
- toc:
permalink: true

View File

@@ -15,13 +15,27 @@ classifiers = [
"License :: OSI Approved :: CeCILL-C", "License :: OSI Approved :: CeCILL-C",
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [ ]
"systemd-python",
]
dynamic = ["version"] dynamic = ["version"]
[project.optional-dependencies]
docs = [
"mkdocs>=1.5",
"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] [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"]}

51
release_note.txt Normal file
View File

@@ -0,0 +1,51 @@
Release notes — pyappengine 0.7
================================
Documentation
-------------
- Added full API reference (Google-style docstrings on all public classes and
methods: AppEngine, CommandsLoader, Commands, AEErrs, AppEngineException).
- Set up MkDocs with Material theme and mkdocstrings for auto-generated
documentation site.
- Added docs/index.md (overview and quick-start example).
- Added docs/getting-started.md (project structure, module authoring guide,
dependency declaration, help system, error handling).
- Added docs/api.md (API reference page, auto-generated from docstrings).
Tests
-----
- Added test suite based on pytest and pytest-cov (88% → 91% coverage).
- tests/conftest.py: shared fixtures and SampleModule helper class.
- tests/test_errors.py: tests for is_number, AEErrs, AppEngineException.
- tests/test_commands.py: tests for Commands (val_to_print, logging, stop,
help, list_modules, execute_command, dispatch).
- tests/test_commands_loader.py: tests for dynamic module loading, nickname
override, config section injection, dependency injection (list and dict
forms), threaded module lifecycle.
- tests/test_app_engine.py: tests for AppEngine init, config parsing, log
setup, stop mechanism.
Bug fixes
---------
- AEErrs.__str__: ERROR_MESSAGES was keyed by enum members but looked up by
integer value (.value). Fixed to look up by enum member.
- execute_command: after resolving an underscore/hyphen module alias, the
original (non-aliased) name was still used for dispatch, causing a KeyError.
Fixed to use the resolved alias.
- execute_command: when parsing "help <module>" (no function name), args were
not cleared before dispatch. This caused cmd_help() to receive the module
name as an argument and return "No command with this name". Fixed to clear
args and kwargs after resolving the module name.
Project / packaging
-------------------
- pyproject.toml: added optional dependency groups [docs] and [dev].
- pyproject.toml: added [tool.pytest.ini_options] (testpaths, pythonpath,
default coverage options).
- .gitignore: added site/, .coverage, htmlcov/, .pytest_cache/.
- README.md: added Development section (installation, running tests, coverage
reports, documentation preview and build).

View File

@@ -1,11 +1,24 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""pyappengine — Python Application Engine.
A framework for building modular, command-based Python applications
with dynamic module loading, thread-safe command dispatch, and
structured lifecycle management.
Typical usage:
```python
from appengine import AppEngine
app = AppEngine("my_app", conf_file="config.ini", debug=True)
app.exec(modpath="./modules")
```
"""
import os import os
import sys import sys
import traceback import traceback
from configparser import ConfigParser from configparser import ConfigParser
import logging import logging
from systemd import journal
import inspect import inspect
from enum import Enum, auto from enum import Enum, auto
import signal import signal
@@ -13,10 +26,18 @@ from importlib import import_module
from threading import Thread, Lock from threading import Thread, Lock
import inspect import inspect
from threading import Thread from threading import Thread, Event
def is_number(s): def is_number(s):
"""Check whether a string represents a numeric value.
Args:
s (str): The string to test.
Returns:
bool: ``True`` if *s* can be parsed as a float, ``False`` otherwise.
"""
try: try:
float(s) float(s)
return True return True
@@ -26,6 +47,16 @@ def is_number(s):
class AEErrs(Enum): class AEErrs(Enum):
"""JSON-RPC-inspired error codes used throughout the engine.
Attributes:
PARSE_ERROR: Invalid JSON received (-32700).
INVALID_REQUEST: Request is not a valid object (-32600).
METH_NOT_FOUND: Method does not exist or is not available (-32601).
INVALID_PARAMS: Invalid method parameters (-32602).
INTERNAL_ERROR: Generic internal error (-32000).
"""
PARSE_ERROR = -32700 PARSE_ERROR = -32700
INVALID_REQUEST = -32600 INVALID_REQUEST = -32600
METH_NOT_FOUND = -32601 METH_NOT_FOUND = -32601
@@ -33,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.
@@ -46,14 +77,88 @@ ERROR_MESSAGES = {
class AppEngineException(Exception): class AppEngineException(Exception):
"""Exception raised by command modules to signal a structured error.
Wraps an :class:`AEErrs` code so that the engine can return a
machine-readable ``(code, message)`` pair to the caller.
Args:
error (AEErrs): The error type.
mesg (str, optional): Custom message. Defaults to the standard
message associated with *error*.
Attributes:
value (int): Numeric error code (from :class:`AEErrs`).
mesg (str): Human-readable error message.
Example:
```python
raise AppEngineException(AEErrs.INVALID_PARAMS, "Expected an integer")
```
"""
def __init__(self, error: AEErrs, mesg=None) -> None: def __init__(self, error: AEErrs, mesg=None) -> None:
if mesg is None: if mesg is None:
self.mesg = str(error) self.mesg = str(error)
else:
self.mesg = mesg
super().__init__(self.mesg) super().__init__(self.mesg)
self.value = error.value self.value = error.value
class Commands(Thread): class Commands(Thread):
"""Base class for all command modules.
Subclass ``Commands`` to create a module. Any method whose name starts
with :attr:`prefcmd` (default ``"cmd_"``) is automatically exposed as a
callable command and listed by the built-in ``help`` system.
A module can optionally run as a background thread by setting
``self.threaded = True`` in ``__init__``. When threaded, the module's
``run()`` method will be executed in a dedicated thread managed by
:class:`CommandsLoader`.
Inter-module dependencies can be declared via a ``dependencies``
attribute (see :meth:`CommandsLoader._load_dependencies`).
Class Attributes:
defmod (str | None): Name of the default module, set by
:class:`CommandsLoader` from the ``[general]`` config section.
prefcmd (str): Prefix for command methods. Defaults to ``"cmd_"``.
Attributes:
threaded (bool): If ``True``, the module runs as a thread.
Defaults to ``False``.
cmods (dict): Shared dictionary of all loaded modules
(``{name: Commands}``), injected by :class:`CommandsLoader`.
config (ConfigParser | None): Configuration section for this module.
log (logging.Logger): Logger instance shared across all modules.
nickname (str | None): Module alias read from ``config["alias"]``.
stopped (bool): Set to ``True`` by :meth:`stop`.
stop_all_event (threading.Event | None): Global stop event,
injected by :class:`CommandsLoader`.
Example:
```python
from appengine import Commands
class MyModule(Commands):
threaded = False
def cmd_greet(self, name: str):
\"\"\"Greet someone.
Args:
name: The person to greet.
Returns:
str: A greeting string.
\"\"\"
return f"Hello, {name}!"
```
Save the file as ``cmds_my_module.py`` in your ``modpath``.
"""
defmod = None defmod = None
prefcmd = "cmd_" prefcmd = "cmd_"
@@ -63,12 +168,22 @@ class Commands(Thread):
self.cmods = {} self.cmods = {}
self.config = config self.config = config
self.log = log self.log = log
self.stop_all_event = None
self.stopped = False self.stopped = False
self.nickname = None self.nickname = None
if not (self.config is None): if not (self.config is None):
self.nickname = self.config.get("alias") self.nickname = self.config.get("alias")
def val_to_print(self, val, dig=1): def val_to_print(self, val, dig=1):
"""Format a value as a printable string.
Args:
val (str | float | Any): The value to format.
dig (int): Number of decimal places for float values. Defaults to 1.
Returns:
str: The formatted string representation of *val*.
"""
if isinstance(val, str): if isinstance(val, str):
return val return val
if isinstance(val, float): if isinstance(val, float):
@@ -78,39 +193,73 @@ class Commands(Thread):
return str(val) return str(val)
def info(self, msg): def info(self, msg):
"""Log an informational message.
Args:
msg (str): Message to log.
"""
if self.log: if self.log:
self.log.info(msg) self.log.info(msg)
else: else:
print("info: " + msg) print("info: " + msg)
def debug(self, msg): def debug(self, msg):
"""Log a debug message.
Args:
msg (str): Message to log.
"""
if self.log: if self.log:
self.log.debug(msg) self.log.debug(msg)
else: else:
print("debug: " + msg) print("debug: " + msg)
def warning(self, msg): def warning(self, msg):
"""Log a warning message.
Args:
msg (str): Message to log.
"""
if self.log: if self.log:
self.log.warning(msg) self.log.warning(msg)
else: else:
print("warning: " + msg) print("warning: " + msg)
def error(self, msg): def error(self, msg):
"""Log an error message.
Args:
msg (str): Message to log.
"""
if self.log: if self.log:
self.log.error(msg) self.log.error(msg)
else: else:
print("error: " + msg) print("error: " + msg)
def stop(self): def stop(self):
"""Signal the module to stop.
Sets :attr:`stopped` to ``True``. Threaded subclasses should check
this flag in their ``run()`` loop to exit gracefully.
"""
self.stopped = True self.stopped = True
def free(self): def free(self):
""" Virtual method used to clean resources for threaded Commands """Release resources held by this module.
when they are stopped.
This is a no-op by default. Override it in subclasses to close
connections, files, or other resources when the application exits.
Called by :meth:`CommandsLoader.free` for every loaded module.
""" """
pass pass
def list_modules(self): def list_modules(self):
"""Return a formatted list of all loaded modules.
Returns:
tuple[bool, str]: ``(True, listing)`` where *listing* is a
newline-separated string of module names.
"""
success = True success = True
ret = "List of modules:\n" ret = "List of modules:\n"
for module in self.cmods.keys(): for module in self.cmods.keys():
@@ -118,6 +267,16 @@ class Commands(Thread):
return success, ret.strip() return success, ret.strip()
def help_module(self, module, *args): def help_module(self, module, *args):
"""Return the help text for a given module or one of its commands.
Args:
module (str): Name of the target module.
*args: If provided, the first element is the command name whose
docstring should be returned.
Returns:
str: Help text, or an error message if the module is not found.
"""
if module in self.cmods.keys(): if module in self.cmods.keys():
if len(args) > 0: if len(args) > 0:
return self.cmods[module].cmd_help(args[0]) return self.cmods[module].cmd_help(args[0])
@@ -127,6 +286,23 @@ class Commands(Thread):
return "No module with this name" return "No module with this name"
def _execute_command(self, method: str, *args, **kwargs) -> tuple: def _execute_command(self, method: str, *args, **kwargs) -> tuple:
"""Execute a command on this module in a thread-safe manner.
Looks up the method ``<prefcmd><method>`` on ``self``, acquires the
shared lock, calls it, and returns the result. Handles
:class:`AppEngineException` and bare exceptions gracefully.
Args:
method (str): Command name (without the ``cmd_`` prefix).
*args: Positional arguments forwarded to the command method.
**kwargs: Keyword arguments forwarded to the command method.
Returns:
tuple[bool, Any]: ``(success, result)`` where *success* is
``False`` if the method was not found or raised an error, and
*result* is either the command return value or an
``(error_code, message)`` tuple.
"""
success = False success = False
ret = (AEErrs.INTERNAL_ERROR.value, "function not found") ret = (AEErrs.INTERNAL_ERROR.value, "function not found")
if hasattr(self, self.prefcmd + method) and inspect.ismethod( if hasattr(self, self.prefcmd + method) and inspect.ismethod(
@@ -169,12 +345,53 @@ class Commands(Thread):
return success, ret return success, ret
def execute_command(self, module: str, method: str, *args, **kwargs): def execute_command(self, module: str, method: str, *args, **kwargs):
"""Dispatch a command to the appropriate module.
Handles the special ``help`` command (with no module prefix) and
resolves underscore/hyphen aliases for module names before
delegating to :meth:`_execute_command`.
Args:
module (str): Target module name. Pass an empty string ``""``
to use :attr:`defmod`, or for the built-in ``help`` routing.
method (str): Command name (without the ``cmd_`` prefix).
*args: Positional arguments forwarded to the command.
**kwargs: Keyword arguments forwarded to the command.
Returns:
tuple[bool, Any]: ``(success, result)`` from
:meth:`_execute_command`, or ``(False, (error_code, message))``
if the module is not found.
"""
# isolate the module called # isolate the module called
success = False success = False
if module == "": if module == "":
if method == 'help': if method == 'help':
return self.list_modules() # No argument was given to the help function
if len(args) == 0 and len(kwargs) == 0:
msg = "Type 'help <module>' to have the list of module's functions.\n"
msg += "Type 'help <module>.<function>' to documentation of a specific function.\n"
success, ret = self.list_modules()
return success, msg + ret
else:
# 1 argument has been provided
if len(args) > 0:
arg = args[0]
else:
arg = list(kwargs.keys())[0]
spl = arg.split(".", 1)
# help <module>
if len(spl) == 1:
module = arg
args = []
kwargs = {}
# help <module>.<method>
else:
module = spl[0]
args = [spl[1]]
kwargs = {}
else: else:
module = self.defmod module = self.defmod
@@ -184,14 +401,31 @@ 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)
def stop_all(self):
"""Trigger a global application stop.
Sets the shared :attr:`stop_all_event`, which signals
:class:`AppEngine` to initiate an orderly shutdown of all modules.
"""
if self.stop_all_event is not None:
self.stop_all_event.set()
def cmd_help(self, *args, **kwargs): def cmd_help(self, *args, **kwargs):
"""Help of module commands. """List available commands or show the docstring of a specific one.
Params:
- if No param: list of commands Args:
- otherwise: help of the first arg""" *args: If empty, all command names are listed. If one argument
is given, it is treated as a command name and its docstring
is returned.
Returns:
str: A newline-separated list of command names, or the
docstring of the requested command.
"""
ret = "" ret = ""
@@ -218,7 +452,31 @@ class Commands(Thread):
class CommandsLoader: class CommandsLoader:
def __init__(self, config: ConfigParser, log: logging.Handler, modpath: str): """Discover, load, and manage the lifecycle of command modules.
Scans *modpath* for Python files whose names match
``<modules_prefix><name>.py`` (default prefix: ``"cmds_"``),
imports each one, and instantiates the first :class:`Commands`
subclass found.
All modules share the same :class:`threading.Lock` and
:class:`threading.Event` (stop signal) injected by :class:`AppEngine`.
Args:
stop_event (threading.Event): Event used to signal a global stop.
config (ConfigParser): Full application configuration.
log (logging.Handler): Logger shared across all modules.
modpath (str): Directory to scan for module files.
Note:
The ``[general]`` config section may override engine defaults:
- ``default``: name of the default module.
- ``methods_prefix``: prefix for command methods (default ``"cmd_"``).
- ``modules_prefix``: prefix for module files (default ``"cmds_"``).
"""
def __init__(self, stop_event: Event, config: ConfigParser, log: logging.Handler, modpath: str):
self.config = config self.config = config
self.modpath = modpath self.modpath = modpath
sys.path.append(os.path.join(modpath)) sys.path.append(os.path.join(modpath))
@@ -232,10 +490,17 @@ class CommandsLoader:
self.prefcmds = prefcmds self.prefcmds = prefcmds
self.log = log self.log = log
self.lock = Lock() self.lock = Lock()
self.stop_event = stop_event
self._load_commands() self._load_commands()
self._set_cmods() self._set_cmods()
def _load_commands(self): def _load_commands(self):
"""Scan *modpath* and load all matching module files.
Populates :attr:`cmods` with successfully instantiated modules.
Each module is keyed by its ``nickname`` (from config) or by its
filename stem stripped of the module prefix.
"""
cmds = {} cmds = {}
with os.scandir(self.modpath) as it: with os.scandir(self.modpath) as it:
for entry in it: for entry in it:
@@ -260,6 +525,19 @@ class CommandsLoader:
self.cmods = cmds self.cmods = cmds
def _load_module(self, name: str): def _load_module(self, name: str):
"""Import a module file and instantiate its :class:`Commands` subclass.
Args:
name (str): Module filename stem (without ``.py``).
Returns:
Commands | None: The instantiated module object, or ``None`` if
no valid :class:`Commands` subclass could be instantiated.
Raises:
Exception: If the Python file cannot be imported at all
(e.g. missing dependency).
"""
try: try:
module = import_module(name) module = import_module(name)
except ModuleNotFoundError: except ModuleNotFoundError:
@@ -273,23 +551,54 @@ class CommandsLoader:
obj = None obj = None
for n, c in members: for n, c in members:
if issubclass(c, Commands) and (n != "Commands"): if issubclass(c, Commands) and (n != "Commands"):
try:
obj = c(conf, self.log) obj = c(conf, self.log)
except:
self.log.error(f"The object '{c.__name__}' could not be instantiated.")
continue
obj.log = self.log obj.log = self.log
obj.lock = self.lock obj.lock = self.lock
obj.stop_all_event = self.stop_event
break break
return obj return obj
def _set_cmods(self): def _set_cmods(self):
"""Inject the shared ``cmods`` dict into every loaded module.
This allows any module to call commands on any other module via
:meth:`Commands.execute_command`.
"""
for k, v in self.cmods.items(): for k, v in self.cmods.items():
v.cmods = self.cmods v.cmods = self.cmods
def _load_dependencies(self): def _load_dependencies(self):
"""Resolve and inject inter-module dependencies.
For each module that declares a ``dependencies`` attribute, the
corresponding module objects are looked up in :attr:`cmods` and set
as attributes on the dependent module.
``dependencies`` can be:
- A **list** of module names: each name becomes both the attribute
name and the key in ``cmods``.
- A **dict** ``{attr_name: module_name}``: allows renaming the
injected attribute.
Logs an error for any dependency that cannot be satisfied.
"""
for k, v in self.cmods.items(): for k, v in self.cmods.items():
if hasattr(v, "dependencies"): if hasattr(v, "dependencies"):
for p in v.dependencies: deps = v.dependencies
if p in self.cmods.keys(): # dependencies can be a list or dictionary
setattr(v, p, self.cmods[p]) if isinstance(v.dependencies, list):
deps = {}
for d in v.dependencies:
deps[d] = d
for p, pv in deps.items():
if pv in self.cmods.keys():
setattr(v, p, self.cmods[pv])
else: else:
self.log.error( self.log.error(
'Dependency "{}" of module "{}" could not be satisfied'.format( 'Dependency "{}" of module "{}" could not be satisfied'.format(
@@ -298,28 +607,75 @@ class CommandsLoader:
) )
def start(self): def start(self):
"""Resolve dependencies and start all threaded modules.
Must be called before :meth:`join`. Non-threaded modules are not
started here — they are invoked on demand via
:meth:`Commands.execute_command`.
"""
self._load_dependencies() self._load_dependencies()
for k, v in self.cmods.items(): for k, v in self.cmods.items():
if v.threaded: if v.threaded:
v.start() v.start()
def stop(self): def stop(self):
"""Signal all threaded modules to stop.
Calls :meth:`Commands.stop` on each module whose ``threaded``
attribute is ``True``.
"""
for k, v in self.cmods.items(): for k, v in self.cmods.items():
if v.threaded: if v.threaded:
v.stop() v.stop()
def join(self): def join(self):
"""Wait for all threaded modules to finish.
Blocks until every module started as a thread has exited its
``run()`` method.
"""
for k, v in self.cmods.items(): for k, v in self.cmods.items():
if v.threaded: if v.threaded:
v.join() v.join()
def free(self): def free(self):
"""Release resources for all loaded modules.
Calls :meth:`Commands.free` on every module regardless of whether
it is threaded.
"""
for k, v in self.cmods.items(): for k, v in self.cmods.items():
if v.threaded:
v.free() v.free()
class AppEngine: class AppEngine:
"""Main entry point for a pyappengine application.
Handles configuration loading, logging setup, signal handling, and
the full module lifecycle (load → start → run → stop → free).
Args:
app_name (str): Application name, also used as the logger name.
conf_file (str): Path to an INI configuration file. Optional.
log_file (str): Path to the log file. If empty, logs go to stderr.
Relative paths are resolved from the current working directory.
debug (bool): If ``True``, sets the log level to ``DEBUG``.
Defaults to ``False`` (``WARNING`` level).
Example:
```python
from appengine import AppEngine
app = AppEngine(
"my_app",
conf_file="config.ini",
log_file="my_app.log",
debug=False,
)
app.exec(modpath="./modules")
```
"""
def __init__( def __init__(
self, self,
app_name: str, app_name: str,
@@ -338,18 +694,47 @@ class AppEngine:
self.log.setLevel(logging.DEBUG) self.log.setLevel(logging.DEBUG)
else: else:
self.log.setLevel(logging.WARNING) self.log.setLevel(logging.WARNING)
# Mechanism to stop the application
self.stop_event = Event()
self.stop_thread = Thread(target=self.wait_stop, args=(self.stop_event,))
self.stop_thread.start()
def signal_handler(self, sig, frame): def signal_handler(self, sig, frame):
"""Handle SIGINT (Ctrl+C) by initiating a clean shutdown.
Args:
sig: Signal number (unused).
frame: Current stack frame (unused).
"""
print("\nExiting.") print("\nExiting.")
self.stop() self.stop()
def parse_config(self, cf: str): def parse_config(self, cf: str):
"""Load an INI configuration file.
Args:
cf (str): Path to the configuration file.
Raises:
Exception: If the file does not exist or is not a regular file.
"""
if os.path.exists(cf) and os.path.isfile(cf): if os.path.exists(cf) and os.path.isfile(cf):
self.conf.read(cf) self.conf.read(cf)
else: else:
raise Exception("Configuration file not found") raise Exception("Configuration file not found")
def def_log(self, log_file: str): def def_log(self, log_file: str):
"""Configure the application logger.
Creates a :class:`logging.Logger` named after :attr:`app_name`.
If *log_file* is provided and the parent directory is writable,
a :class:`logging.FileHandler` is added. Otherwise logs go to
the default stream handler (stderr).
Args:
log_file (str): Destination log file path. Empty string disables
file logging.
"""
self.log = logging.getLogger(self.app_name) self.log = logging.getLogger(self.app_name)
if log_file != "": if log_file != "":
fname = log_file fname = log_file
@@ -364,17 +749,40 @@ class AppEngine:
if is_writeable: if is_writeable:
self.log.addHandler(logging.FileHandler(fname)) self.log.addHandler(logging.FileHandler(fname))
else: else:
self.log.addHandler(journal.JournalHandler())
self.log.error('No write permissions: "{}"'.format(fname)) self.log.error('No write permissions: "{}"'.format(fname))
else:
self.log.addHandler(journal.JournalHandler())
def exec(self, modpath: str = ""): def exec(self, modpath: str = ""):
self.cl = CommandsLoader(self.conf, self.log, modpath) """Load modules and run the application until stopped.
Creates a :class:`CommandsLoader`, starts all threaded modules,
then blocks until they all finish. Calls :meth:`CommandsLoader.free`
on exit.
Args:
modpath (str): Directory containing the module files
(``cmds_*.py``).
"""
self.cl = CommandsLoader(self.stop_event, self.conf, self.log, modpath)
self.cl.start() self.cl.start()
self.cl.join() self.cl.join()
self.cl.free() self.cl.free()
def stop(self): def wait_stop(self, evnt):
"""Wait for the stop event and then halt all modules.
Runs in a dedicated background thread started in ``__init__``.
Blocks on *evnt* and calls :meth:`CommandsLoader.stop` once it fires.
Args:
evnt (threading.Event): The event to wait on.
"""
evnt.wait()
self.cl.stop() self.cl.stop()
def stop(self):
"""Stop the application.
Sets the internal stop event, which unblocks :meth:`wait_stop`
and triggers an orderly shutdown of all modules.
"""
self.stop_event.set()

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