Source code for httk.web.functions.python_module

import hashlib
import importlib.util
import sys
import threading
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType
from typing import Any


[docs] class PythonFunctionHandler: def __init__(self, functions_dir: Path) -> None:
[docs] self.functions_dir = functions_dir
self._module_cache: dict[Path, ModuleType] = {} self._cache_lock = threading.Lock() self._sys_path_lock = threading.Lock() self._sys_path_refcount: dict[str, int] = {}
[docs] def execute(self, *, function_name: str, params: dict[str, str], global_data: dict[str, object]) -> Any: module_path = self._resolve_function_path(function_name) module = self._load_module(module_path) execute_fn = getattr(module, "execute", None) if execute_fn is None or not callable(execute_fn): raise ValueError(f"Function module missing callable execute(): {module_path}") callargs: dict[str, object] = dict(params) callargs["global_data"] = global_data with self._function_import_paths(module_path): return execute_fn(**callargs)
def _resolve_function_path(self, function_name: str) -> Path: candidate = function_name.strip() if not candidate: raise ValueError("Function name cannot be empty") rel = Path(candidate) if rel.is_absolute(): raise ValueError(f"Function path must be relative: {function_name}") with_suffix = rel if rel.suffix == ".py" else rel.with_suffix(".py") resolved = (self.functions_dir / with_suffix).resolve(strict=False) try: resolved.relative_to(self.functions_dir.resolve(strict=False)) except ValueError as exc: raise ValueError(f"Function path escapes functions directory: {function_name}") from exc if not resolved.exists() or not resolved.is_file(): raise FileNotFoundError(f"Function module not found: {resolved}") return resolved def _load_module(self, module_path: Path) -> ModuleType: cached = self._module_cache.get(module_path) if cached is not None: return cached with self._cache_lock: cached = self._module_cache.get(module_path) if cached is not None: return cached digest = hashlib.sha256(str(module_path).encode("utf-8")).hexdigest()[:16] module_name = f"httk_web_userfunc_{digest}" spec = importlib.util.spec_from_file_location(module_name, module_path) if spec is None or spec.loader is None: raise ImportError(f"Cannot load module spec for {module_path}") module = importlib.util.module_from_spec(spec) with self._function_import_paths(module_path): spec.loader.exec_module(module) self._module_cache[module_path] = module return module @contextmanager def _function_import_paths(self, module_path: Path): paths: list[str] = [] for path in (self.functions_dir.resolve(strict=False), module_path.parent.resolve(strict=False)): path_str = str(path) if path_str in paths: continue paths.append(path_str) for acquired_path in paths: self._acquire_sys_path(acquired_path) try: yield finally: for acquired_path in reversed(paths): self._release_sys_path(acquired_path) def _acquire_sys_path(self, path: str) -> None: with self._sys_path_lock: current = self._sys_path_refcount.get(path, 0) if current == 0: sys.path.insert(0, path) self._sys_path_refcount[path] = current + 1 def _release_sys_path(self, path: str) -> None: with self._sys_path_lock: current = self._sys_path_refcount.get(path, 0) if current <= 1: self._sys_path_refcount.pop(path, None) try: sys.path.remove(path) except ValueError: pass return self._sys_path_refcount[path] = current - 1