Skip to content
Merged
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## v2.3.0 (2025-12-23)

### Feat

- **node**: enhance trigger handling with asyncio task management
- **tests**: add yappi profiling context manager and integrate into test cases
- **plugins**: add JSON representation method to InstalledModule

### Fix

- **files**: ensure temporary file cleanup on error in write_json_secure

### Refactor

- **node, plugins**: improve event handling and entry point validation
- **plugins**: enhance module handling and entry point loading
- **setup**: improve error logging and simplify entry point handling

## v2.2.0 (2025-12-12)

### Feat
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "funcnodes-core"

version = "2.2.0"
version = "2.3.0"

description = "core package for funcnodes"
authors = [{name = "Julian Kimmig", email = "[email protected]"}]
Expand Down
27 changes: 13 additions & 14 deletions src/funcnodes_core/_setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import Dict, Optional
import gc
from .config import update_render_options
from .lib import check_shelf
from ._logging import FUNCNODES_LOGGER
from .utils.plugins import get_installed_modules, InstalledModule, PLUGIN_FUNCTIONS

Expand All @@ -10,8 +9,8 @@ def setup_module(mod_data: InstalledModule) -> Optional[InstalledModule]:
gc.collect()
entry_points = mod_data.entry_points
mod = mod_data.module
if not mod: # funcnodes modules must have an module entry point
return None
# if not mod: # funcnodes modules must have an module entry point
# return None

# first we try to register the plugin setup function as this might register other functions
try:
Expand All @@ -21,15 +20,15 @@ def setup_module(mod_data: InstalledModule) -> Optional[InstalledModule]:
mod.FUNCNODES_PLUGIN_SETUP()
entry_points["render_options"] = mod.FUNCNODES_PLUGIN_SETUP
except Exception as e:
FUNCNODES_LOGGER.error("Error in plugin setup %s: %s" % (mod.__name__, e))
FUNCNODES_LOGGER.error("Error in plugin setup %s: %s" % (mod_data.name, e))

# Then we call the plugin functions
for pluginf in PLUGIN_FUNCTIONS.values():
try:
pluginf(mod_data)
except Exception as e:
FUNCNODES_LOGGER.error(
"Error in setup_module plugin function %s: %s" % (mod.__name__, e)
"Error in setup_module plugin function %s: %s" % (mod_data.name, e)
)

if "render_options" in entry_points:
Expand All @@ -48,15 +47,15 @@ def setup_module(mod_data: InstalledModule) -> Optional[InstalledModule]:
if hasattr(mod, sn):
entry_points["shelf"] = getattr(mod, sn)
break
if "shelf" in entry_points:
try:
entry_points["shelf"] = check_shelf(
entry_points["shelf"], parent_id=mod_data.name
)
except ValueError as e:
FUNCNODES_LOGGER.error("Error in module %s: %s" % (mod.__name__, e))
del entry_points["shelf"]
mod_data._is_setup = True
# if "shelf" in entry_points:
# try:
# entry_points["shelf"] = check_shelf(
# entry_points["shelf"], parent_id=mod_data.name
# )
# except ValueError as e:
# FUNCNODES_LOGGER.error("Error in module %s: %s" % (mod_data.name, e))
# del entry_points["shelf"]
mod_data._is_setup = mod is not None
return mod_data


Expand Down
30 changes: 27 additions & 3 deletions src/funcnodes_core/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ def __init__(
self._outputs: List[NodeOutput] = []
self._outputs_dict: Optional[Dict[str, NodeOutput]] = None
self._triggerstack: Optional[TriggerStack] = None
self._trigger_task: Optional[asyncio.Task] = None
# flag whether the trigger has started but still not read the ios
self._trigger_open = False
self._requests_trigger = False
Expand Down Expand Up @@ -724,6 +725,8 @@ def in_trigger(self):
checked if the triggerstack is not None and if it is done
or if the _trigger_open flag is set
"""
if self._trigger_task is not None and self._trigger_task.done():
self._trigger_task = None
if self._triggerstack is not None:
if self._triggerstack.done():
self._triggerstack = None
Expand Down Expand Up @@ -1100,8 +1103,26 @@ async def await_trigger(self):

@savemethod
async def wait_for_trigger_finish(self):
if self.in_trigger:
await self.asynceventmanager.wait("triggerdone")
while self.in_trigger:
task = self._trigger_task
if task is not None:
try:
# Wait on the trigger task directly to avoid missing event pulses.
await asyncio.wait_for(asyncio.shield(task), timeout=0.5)
except asyncio.TimeoutError:
continue
else:
try:
# Fallback when no task reference is available.
await asyncio.wait_for(
self.asynceventmanager.wait("triggerdone"), timeout=0.5
)
except asyncio.TimeoutError:
continue

def _clear_trigger_task(self, task: asyncio.Task) -> None:
if self._trigger_task is task:
self._trigger_task = None

@savemethod
async def await_until_complete(self):
Expand Down Expand Up @@ -1145,7 +1166,10 @@ def trigger(self, triggerstack: Optional[TriggerStack] = None) -> TriggerStack:
triggerlogger.debug("triggering %s", self)
self._trigger_open = True
self._triggerstack = triggerstack
self._triggerstack.append(asyncio.create_task(self()))
task = asyncio.create_task(self())
self._trigger_task = task
task.add_done_callback(self._clear_trigger_task)
self._triggerstack.append(task)
self._requests_trigger = False
return self._triggerstack

Expand Down
6 changes: 4 additions & 2 deletions src/funcnodes_core/utils/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def write_json_secure(data, filepath: Union[Path, str], cls=None, **kwargs):
cls = cls or JSONEncoder

# Create a temporary file in the same directory
temp_file_path = None
try:
with tempfile.NamedTemporaryFile(
"w+", dir=directory, delete=False, encoding="utf-8"
Expand All @@ -33,8 +34,9 @@ def write_json_secure(data, filepath: Union[Path, str], cls=None, **kwargs):
os.fsync(temp_file.fileno()) # Force writing to disk for durability
except Exception as e:
# Clean up the temporary file in case of an error
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
if temp_file_path:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
raise e

# Atomically replace the target file with the temporary file
Expand Down
48 changes: 38 additions & 10 deletions src/funcnodes_core/utils/plugins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from typing import Dict, Optional
from collections.abc import Callable
from importlib.metadata import entry_points, Distribution, PackageNotFoundError
from importlib.metadata import (
EntryPoint,
entry_points,
Distribution,
PackageNotFoundError,
)
from importlib import reload
import sys
from .._logging import FUNCNODES_LOGGER
Expand Down Expand Up @@ -44,13 +49,15 @@ def reload_plugin_module(module_name: str):

def assert_entry_points_loaded(modulde_data: InstalledModule):
for ep in entry_points(group="funcnodes.module", module=modulde_data.name):
if ep.name in modulde_data.entry_points:
if ep.name in modulde_data.entry_points and not isinstance(
modulde_data.entry_points[ep.name], EntryPoint
):
continue
try:
loaded = ep.load()
modulde_data.entry_points[ep.name] = loaded
if ep.name == "module":
modulde_data.module = loaded
modulde_data.set_module(loaded)
except Exception as exc:
FUNCNODES_LOGGER.exception(f"Failed to load entry point {ep.name}: {exc}")

Expand Down Expand Up @@ -106,24 +113,45 @@ def _lazydist() -> Distribution:
return modulde_data


def setup_plugin_module(module_name: str) -> Optional[InstalledModule]:
modulde_data = InstalledModule(
name=module_name,
entry_points={},
module=None, # module not directly added since only modules with a module entry point are relevant
)

for ep in entry_points(group="funcnodes.module", module=modulde_data.name):
if ep.name in modulde_data.entry_points:
continue
modulde_data.entry_points[ep.name] = ep

return modulde_data


def get_installed_modules(
named_objects: Optional[Dict[str, InstalledModule]] = None,
) -> Dict[str, InstalledModule]:
if named_objects is None:
named_objects: Dict[str, InstalledModule] = {}

modules = set()

for ep in entry_points(group="funcnodes.module"):
module_name = ep.module
modules.add(module_name)
for module_name in modules:
if module_name not in named_objects:
if module_name in named_objects:
continue
# insmod = setup_plugin_module(module_name)
# if not insmod:
# continue
# named_objects[module_name] = insmod

# old code
if module_name in sys.modules:
named_objects[module_name] = reload_plugin_module(module_name)
else:
named_objects[module_name] = setup_plugin_module(module_name)
modulde_data = named_objects[module_name]
modulde_data = assert_entry_points_loaded(modulde_data)
modulde_data = assert_module_metadata(modulde_data)

for module_name, modulde_data in named_objects.items():
modulde_data = assert_module_metadata(modulde_data)
return named_objects


Expand Down
47 changes: 46 additions & 1 deletion src/funcnodes_core/utils/plugins_types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Dict, Any, Optional, TypedDict, List
from dataclasses import dataclass, field
from importlib.metadata import EntryPoint


class RenderOptions(TypedDict, total=False):
Expand Down Expand Up @@ -27,6 +28,29 @@ class BasePlugin(TypedDict):
module: str


class _LazyEntryDict(dict):
def __getitem__(self, name: str) -> Any:
value = super().__getitem__(name)
if isinstance(value, EntryPoint):
value = value.load()
self[name] = value
if hasattr(self, "installed_module"):
installed_module = getattr(self, "installed_module")
if installed_module.module is None:
if name == "module":
installed_module.set_module(value)
else:
module = self.get("module", None)
if module is not None:
installed_module.set_module(module)
return value

def get(self, name: str, default: Any = None) -> Any:
if name not in self:
return default
return self[name]


@dataclass
class InstalledModule:
"""
Expand All @@ -40,12 +64,18 @@ class InstalledModule:
name: str
module: Any
description: Optional[str] = None
entry_points: Dict[str, Any] = field(default_factory=dict)
entry_points: Dict[str, Any] = field(default_factory=_LazyEntryDict)
plugins: List[BasePlugin] = field(default_factory=list)
render_options: Optional[RenderOptions] = None
version: Optional[str] = None
_is_setup = False

# make sure that entrz points is a _LazyEntryDict
def __post_init__(self):
if not isinstance(self.entry_points, _LazyEntryDict):
self.entry_points = _LazyEntryDict(self.entry_points)
self.entry_points.installed_module = self

@property
def rep_dict(self) -> dict[str, Any]:
return {
Expand All @@ -62,3 +92,18 @@ def __repr__(self) -> str:

def __str__(self) -> str:
return self.__repr__()

def set_module(self, module: Any):
if self.module is not None:
if self.module != module:
raise ValueError(
f"Module {self.name} already has a module {self.module} and cannot be set to {module}"
)
self.module = module
if not self._is_setup:
from .._setup import setup_module

setup_module(self)

def _repr_json_(self) -> dict[str, Any]:
return self.rep_dict
39 changes: 39 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import pathlib
import os
import pytest

try:
import yappi
except ImportError:
yappi = None


class _yappicontext:
def __init__(self, file):
base_dir = pathlib.Path(
os.environ.get("TEST_OUTPUT_DIR", "testouts")
).absolute()
if not base_dir.exists():
base_dir.mkdir(parents=True, exist_ok=True)
self.file = str(base_dir / file)

def __enter__(self):
if yappi is not None:
yappi.set_clock_type("WALL")
yappi.start()

def __exit__(self, exc_type, exc_val, exc_tb):
if yappi is not None:
yappi.stop()
yappi.get_func_stats().save(self.file, "pstat")
yappi.clear_stats()


@pytest.fixture
def yappicontext_class():
return _yappicontext


@pytest.fixture
def yappicontext(yappicontext_class, request):
return yappicontext_class(request.node.name + ".pstat")
Loading