Skip to content
Merged

Dev #81

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 53 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"}]
Expand Down
59 changes: 59 additions & 0 deletions src/funcnodes_worker/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,13 +541,37 @@ 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
name: str | None
data_path: Optional[str]
env_path: Optional[str]
pid: Optional[int]
autostart: AutostartPolicy

# shelves_dependencies: Dict[str, ShelfDict]
worker_dependencies: Dict[str, WorkerDict]
Expand Down Expand Up @@ -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={},
)
)
Expand All @@ -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"]:
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading