Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6938b5c2b2 | |||
| b2220b5bbd | |||
| 779d8cee44 | |||
| 5d8865a9fa | |||
|
|
5a60e47c12 | ||
|
|
355915e9c1 | ||
|
|
0da36439a6 | ||
|
|
3d402db2c2 | ||
|
|
de04b2b3b9 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
25
docs/api.md
Normal file
25
docs/api.md
Normal 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
103
docs/getting-started.md
Normal 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
65
docs/index.md
Normal 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
51
mkdocs.yml
Normal 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
|
||||||
@@ -18,8 +18,24 @@ classifiers = [
|
|||||||
dependencies = [ ]
|
dependencies = [ ]
|
||||||
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
51
release_note.txt
Normal 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).
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
#!/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
|
||||||
@@ -16,6 +30,14 @@ 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
|
||||||
@@ -25,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
|
||||||
@@ -32,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.
|
||||||
@@ -45,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_"
|
||||||
|
|
||||||
@@ -69,6 +175,15 @@ class Commands(Thread):
|
|||||||
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,18 +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):
|
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:
|
if self.stop_all_event is not None:
|
||||||
self.stop_all_event.set()
|
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 = ""
|
||||||
|
|
||||||
@@ -222,6 +452,30 @@ class Commands(Thread):
|
|||||||
|
|
||||||
|
|
||||||
class CommandsLoader:
|
class CommandsLoader:
|
||||||
|
"""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):
|
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
|
||||||
@@ -241,6 +495,12 @@ class CommandsLoader:
|
|||||||
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:
|
||||||
@@ -265,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:
|
||||||
@@ -278,7 +551,11 @@ 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"):
|
||||||
obj = c(conf, self.log)
|
try:
|
||||||
|
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
|
obj.stop_all_event = self.stop_event
|
||||||
@@ -287,15 +564,41 @@ class CommandsLoader:
|
|||||||
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(
|
||||||
@@ -304,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,
|
||||||
@@ -350,16 +700,41 @@ class AppEngine:
|
|||||||
self.stop_thread.start()
|
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
|
||||||
@@ -377,14 +752,37 @@ class AppEngine:
|
|||||||
self.log.error('No write permissions: "{}"'.format(fname))
|
self.log.error('No write permissions: "{}"'.format(fname))
|
||||||
|
|
||||||
def exec(self, modpath: str = ""):
|
def exec(self, modpath: str = ""):
|
||||||
|
"""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 = 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 wait_stop(self, evnt):
|
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()
|
evnt.wait()
|
||||||
self.cl.stop()
|
self.cl.stop()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.stop_event.set()
|
"""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
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