diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aea375..cbc8d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.6.0 (2026-05-06) + +### Feat + +- **worker**: enhance autostart configuration with new policy options +- **worker**: add methods to get and update worker configuration +- **worker**: add autostart configuration option with default value + ## 1.5.1 (2025-12-18) ### Refactor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4a1bbb8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to funcnodes-worker + +This repository contains the **worker runtime** used to execute FuncNodes graphs (WebSocket RPC server, loops, worker config). + +## Development setup (Python) + +Prereqs: + +- Python **3.11+** +- `uv` (https://github.com/astral-sh/uv) + +Recommended environment variables (keep caches/config local): + +- `UV_CACHE_DIR=.cache/uv` +- `FUNCNODES_CONFIG_DIR=.funcnodes` + +Install dev dependencies: + +```bash +cd funcnodes_worker +UV_CACHE_DIR=.cache/uv uv sync --group dev +``` + +Run unit tests: + +```bash +cd funcnodes_worker +FUNCNODES_CONFIG_DIR=.funcnodes UV_CACHE_DIR=.cache/uv uv run pytest +``` + +## Full test matrix (tox) + +`funcnodes-worker` has optional extras (`venv`, `http`, `subprocess-monitor`) and a tox matrix to test combinations. + +```bash +cd funcnodes_worker +FUNCNODES_CONFIG_DIR=.funcnodes UV_CACHE_DIR=.cache/uv uv run tox +``` + +## Code style & hooks + +Run pre-commit: + +```bash +cd funcnodes_worker +UV_CACHE_DIR=.cache/uv uv run pre-commit install +UV_CACHE_DIR=.cache/uv uv run pre-commit run -a +``` + +## TDD expectations + +- Write tests first; cover edge cases. +- Avoid mocks unless simulating external resources. diff --git a/README.md b/README.md index e69de29..95ad281 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,45 @@ +# FuncNodes Worker + +**FuncNodes Worker** (`funcnodes_worker`) is the execution engine for the [FuncNodes](https://github.com/Linkdlab/FuncNodes) ecosystem. It provides the runtime environment where nodes are executed, managed, and controlled. + +> [!NOTE] +> For the full application usage, please refer to the main [FuncNodes repository](https://github.com/Linkdlab/FuncNodes). + +## Key Features + +- **Execution Runtime**: Manages the `NodeSpace` and the main event loop, ensuring efficient asynchronous execution of nodes. +- **Remote Control**: + - **WebSocket Worker**: Allows remote management and frontend interaction via `aiohttp` WebSockets. + - **Message Queue Worker**: Supports communication via multiprocessing queues for inter-process coordination. +- **Process Management**: Handles state persistence, heartbeats, and safe shutdown procedures. +- **Extensibility**: Supports "External Workers" to offload tasks to auxiliary processes or environments. + +## Installation + +```bash +pip install funcnodes-worker +``` + +## Dependencies + +- **funcnodes-core**: The core logic definitions. +- **aiohttp**: For WebSocket communication (optional but recommended). +- **pydantic**: For configuration and data validation. + +## Usage + +In most cases, `funcnodes-worker` is used internally by the `funcnodes` main package. However, you can instantiate a worker programmatically if needed: + +```python +from funcnodes_worker import WSWorker + +# Create a worker that listens on a specific host and port +worker = WSWorker(host="localhost", port=9382) + +# Start the worker loop (usually handled by an async runner) +# await worker.run() +``` + +## Documentation + +For comprehensive documentation, visit the [FuncNodes Documentation](https://linkdlab.github.io/FuncNodes). diff --git a/pyproject.toml b/pyproject.toml index ae49979..af0f2db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "funcnodes-worker" -version = "1.5.1" +version = "1.6.0" description = "Worker package for FuncNodes" readme = "README.md" authors = [{name = "Julian Kimmig", email = "julian.kimmig@linkdlab.de"}] diff --git a/src/funcnodes_worker/worker.py b/src/funcnodes_worker/worker.py index 8f38ba9..126516c 100644 --- a/src/funcnodes_worker/worker.py +++ b/src/funcnodes_worker/worker.py @@ -541,6 +541,29 @@ class PossibleUpdates(TypedDict, total=False): funcnodes: bool +AutostartPolicy = Literal["never", "always", "unless-stopped"] +AUTOSTART_POLICIES: tuple[AutostartPolicy, ...] = ( + "never", + "always", + "unless-stopped", +) + + +def normalize_autostart_policy(value: object = None) -> AutostartPolicy: + if value is True: + return "unless-stopped" + if value in (False, None): + return "never" + if isinstance(value, str): + if value in AUTOSTART_POLICIES: + return cast(AutostartPolicy, value) + raise ValueError( + "Invalid autostart policy " + f"{value!r}. Expected one of {', '.join(AUTOSTART_POLICIES)}." + ) + raise ValueError(f"Invalid autostart policy {value!r}.") + + class WorkerJson(TypedDict): type: str uuid: str @@ -548,6 +571,7 @@ class WorkerJson(TypedDict): data_path: Optional[str] env_path: Optional[str] pid: Optional[int] + autostart: AutostartPolicy # shelves_dependencies: Dict[str, ShelfDict] worker_dependencies: Dict[str, WorkerDict] @@ -770,6 +794,7 @@ def generate_config(self) -> WorkerJson: worker_dependencies=worker_dependencies, package_dependencies=self._package_dependencies.copy(), pid=os.getpid(), + autostart="never", update_on_startup={}, ) ) @@ -785,6 +810,8 @@ def update_config(self, conf: WorkerJson) -> WorkerJson: if "update_on_startup" not in conf: conf["update_on_startup"] = {} # pragma: no cover + conf["autostart"] = normalize_autostart_policy(conf.get("autostart")) + if "funcnodes" not in conf["update_on_startup"]: conf["update_on_startup"]["funcnodes"] = True # pragma: no cover if "funcnodes-core" not in conf["update_on_startup"]: @@ -975,6 +1002,38 @@ def export_worker(self, with_files: bool = True) -> bytes: return zip_bytes + @exposed_method() + def get_config(self) -> WorkerJson: + """returns the current worker configuration""" + return self.update_config(self.config) + + @exposed_method() + def update_worker_config( + self, + name: Optional[str] = None, + autostart: Optional[AutostartPolicy | bool] = None, + update_on_startup: Optional[PossibleUpdates] = None, + ) -> WorkerJson: + """updates editable worker configuration values""" + config = self.update_config(self.config) + + if name is not None: + stripped_name = name.strip() + self._name = stripped_name or None + config["name"] = self._name + + if autostart is not None: + config["autostart"] = normalize_autostart_policy(autostart) + + if update_on_startup is not None: + current_update_on_startup = config.get("update_on_startup", {}) + current_update_on_startup.update( + {key: bool(value) for key, value in update_on_startup.items()} + ) + config["update_on_startup"] = current_update_on_startup + + return self.write_config(config) + @exposed_method() async def update( self, diff --git a/tests/test_worker.py b/tests/test_worker.py index 848fb1d..b33b6ec 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -249,6 +249,7 @@ async def test_worker_case_config_generation(worker_case): "pid": os.getpid(), "type": worker_case.__class__.__name__, "env_path": None, + "autostart": "never", "update_on_startup": { "funcnodes": True, "funcnodes-core": True, @@ -266,6 +267,7 @@ async def test_worker_case_exportable_config(worker_case): "name": worker_case.name(), "package_dependencies": {}, "type": worker_case.__class__.__name__, + "autostart": "never", "update_on_startup": { "funcnodes": True, "funcnodes-core": True, @@ -291,6 +293,45 @@ async def test_worker_case_load_config(worker_case): assert config["uuid"] == worker_case.uuid() +@funcnodes_test +async def test_worker_case_missing_autostart_defaults_never(worker_case): + config = worker_case.config + config.pop("autostart", None) + + updated = worker_case.update_config(config) + + assert updated["autostart"] == "never" + + +@funcnodes_test +async def test_worker_case_legacy_autostart_booleans_migrate(worker_case): + true_config = worker_case.update_config({**worker_case.config, "autostart": True}) + false_config = worker_case.update_config({**worker_case.config, "autostart": False}) + + assert true_config["autostart"] == "unless-stopped" + assert false_config["autostart"] == "never" + + +@funcnodes_test +async def test_worker_case_update_worker_config(worker_case): + updated = worker_case.update_worker_config( + name="renamed worker", + autostart="unless-stopped", + update_on_startup={"funcnodes": False}, + ) + + assert updated["name"] == "renamed worker" + assert updated["autostart"] == "unless-stopped" + assert updated["update_on_startup"]["funcnodes"] is False + assert updated["update_on_startup"]["funcnodes-core"] is True + assert worker_case.name() == "renamed worker" + + loaded = worker_case.load_config() + assert loaded is not None + assert loaded["name"] == "renamed worker" + assert loaded["autostart"] == "unless-stopped" + + @funcnodes_test async def test_worker_case_process_file_handling(worker_case): worker_case._write_process_file() diff --git a/uv.lock b/uv.lock index e922524..6a6cdba 100644 --- a/uv.lock +++ b/uv.lock @@ -473,7 +473,7 @@ wheels = [ [[package]] name = "funcnodes-worker" -version = "1.5.1" +version = "1.6.0" source = { editable = "." } dependencies = [ { name = "asynctoolkit" },