diff --git a/pyproject.toml b/pyproject.toml index bee146c..b45848d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ "Topic :: Software Development :: User Interfaces", "Framework :: FastAPI", ] -dependencies = ["qh", "ju", "i2", "dol", "meshed"] +dependencies = ["qh", "ju>=0.1.33", "i2", "dol", "meshed"] [project.urls] Homepage = "https://github.com/i2mint/uf" diff --git a/tests/test_specs.py b/tests/test_specs.py index 110638d..adc4bb2 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -104,3 +104,112 @@ def test_get_func(): func = store.get_func('sample_add') assert func == sample_add assert func(10, 20) == 30 + + +# --- ju integration (i2mint/uf#4) ------------------------------------------- +# These tests take the ju path with no fallback guard. If ju is not installed +# they skip with a visible reason; if ju is installed but fails to import (e.g. +# an undeclared dependency of ju), they fail with the real traceback. + +import importlib +import importlib.abc +import sys +import warnings + +from uf.specs import JuNotInstalledWarning, _import_func_to_form_spec + + +def _require_ju(): + """Import ``ju.rjsf``; skip only if ``ju`` itself is absent.""" + try: + return importlib.import_module('ju.rjsf') + except ModuleNotFoundError as e: + if e.name == 'ju': + pytest.skip(f'ju not installed ({e.name!r} missing)') + raise + + +def test_ju_path_is_taken_without_fallback(): + """The spec must come from ju, not from the basic fallback.""" + _require_ju() + with warnings.catch_warnings(): + warnings.simplefilter('error', JuNotInstalledWarning) + spec = FunctionSpecStore([sample_add])['sample_add'] + + schema = spec['schema'] + assert schema['properties'] == { + 'x': {'type': 'integer'}, + 'y': {'type': 'integer'}, + } + # The basic fallback never emits a uiSchema; ju always does. + assert spec['uiSchema'], 'empty uiSchema: the basic fallback ran, not ju' + + +def test_ju_required_fields(): + """Params without defaults are required; defaulted ones are not (ju>=0.1.33).""" + _require_ju() + schema = FunctionSpecStore([sample_greet])['sample_greet']['schema'] + assert schema['required'] == ['name'] + assert schema['properties']['greeting'].get('default') == 'Hello' + + +class _JuAbsentFinder(importlib.abc.MetaPathFinder): + """Make ``ju`` look uninstalled, exactly as a real absence does.""" + + def find_spec(self, fullname, path=None, target=None): + if fullname == 'ju': + raise ModuleNotFoundError("No module named 'ju'", name='ju') + return None + + +def _block_ju(monkeypatch): + for name in [m for m in sys.modules if m == 'ju' or m.startswith('ju.')]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.setattr(sys, 'meta_path', [_JuAbsentFinder(), *sys.meta_path]) + + +def test_missing_ju_falls_back_with_visible_warning(monkeypatch): + _block_ju(monkeypatch) + with pytest.warns(JuNotInstalledWarning, match='not installed'): + spec = FunctionSpecStore([sample_greet])['sample_greet'] + assert spec['schema']['required'] == ['name'] + assert spec['uiSchema'] == {} + + +class _BrokenJuRjsfFinder(importlib.abc.MetaPathFinder): + """Simulate ju being installed but one of its own dependencies missing.""" + + def find_spec(self, fullname, path=None, target=None): + if fullname == 'ju.rjsf': + raise ModuleNotFoundError("No module named 'dill'", name='dill') + return None + + +def test_broken_ju_dependency_raises_instead_of_falling_back(monkeypatch): + _require_ju() + monkeypatch.delitem(sys.modules, 'ju.rjsf', raising=False) + monkeypatch.setattr(sys, 'meta_path', [_BrokenJuRjsfFinder(), *sys.meta_path]) + monkeypatch.delattr(sys.modules['ju'], 'rjsf', raising=False) + with pytest.raises(ModuleNotFoundError) as excinfo: + _import_func_to_form_spec() + assert excinfo.value.name == 'dill' + + +def test_missing_ju_rjsf_submodule_raises(monkeypatch): + """ju present but ju.rjsf absent (too-old ju) is a broken install, not 'absent'.""" + _require_ju() + monkeypatch.setitem(sys.modules, 'ju.rjsf', None) + monkeypatch.delattr(sys.modules['ju'], 'rjsf', raising=False) + with pytest.raises(ModuleNotFoundError) as excinfo: + _import_func_to_form_spec() + assert excinfo.value.name == 'ju.rjsf' + + +def test_other_import_errors_propagate(monkeypatch): + """An ImportError that is not 'ju is absent' (e.g. a renamed symbol) raises.""" + _require_ju() + fake = type(sys)('ju.rjsf') # module without func_to_form_spec + monkeypatch.setitem(sys.modules, 'ju.rjsf', fake) + monkeypatch.setattr(sys.modules['ju'], 'rjsf', fake, raising=False) + with pytest.raises(ImportError, match='func_to_form_spec'): + _import_func_to_form_spec() diff --git a/uf/specs.py b/uf/specs.py index cc9ba97..1d645c5 100644 --- a/uf/specs.py +++ b/uf/specs.py @@ -4,10 +4,46 @@ form specs and OpenAPI schemas. """ +import warnings from typing import Callable, Iterable, Optional, Any from collections.abc import Mapping from functools import cached_property +# Only the absence of top-level ``ju`` means "not installed" (fall back). A missing +# ``ju.rjsf`` (too-old ju, stray namespace dir) or a missing dependency *of* ju is +# a broken install: let it raise. +_JU_MODULE_NAMES = ("ju",) + + +class JuNotInstalledWarning(UserWarning): + """Emitted when ``ju`` is absent and uf falls back to basic form specs.""" + + +def _import_func_to_form_spec() -> Optional[Callable]: + """Return ``ju.rjsf.func_to_form_spec``, or ``None`` if ``ju`` is not installed. + + Only the absence of ``ju`` itself is tolerated (with a visible warning). + Any other import failure -- a missing dependency of ``ju``, a renamed + symbol -- is a broken environment and propagates, so it fails loudly + instead of silently degrading every generated form. + + >>> _import_func_to_form_spec() is None or callable(_import_func_to_form_spec()) + True + """ + try: + from ju.rjsf import func_to_form_spec + except ModuleNotFoundError as e: + if e.name not in _JU_MODULE_NAMES: + raise + warnings.warn( + f"{e.name!r} is not installed; uf is falling back to basic form " + "specs (no ju/RJSF features). Install it with `pip install ju`.", + JuNotInstalledWarning, + stacklevel=4, # caller -> __getitem__ -> _generate_spec -> here + ) + return None + return func_to_form_spec + class FunctionSpecStore(Mapping): """A mapping from function names to their RJSF specifications. @@ -88,39 +124,34 @@ def _generate_spec(self, func_name: str) -> dict: """ func = self._funcs[func_name] - try: - # Import ju.rjsf for form spec generation - from ju.rjsf import func_to_form_spec - - # Generate form spec - # ju nests the spec under a "rjsf" field by default; uf wants it flat - rjsf_config = {"nest_under_field": None, **self._rjsf_config} - form_spec = func_to_form_spec(func, **rjsf_config) - - # Apply custom UI schema factory if provided - if self._ui_schema_factory: - ui_schema = self._ui_schema_factory(func) - if "uiSchema" in form_spec: - form_spec["uiSchema"].update(ui_schema) - else: - form_spec["uiSchema"] = ui_schema - - return { - "schema": form_spec.get("schema", {}), - "uiSchema": form_spec.get("uiSchema", {}), - "func": func, - "name": func_name, - "description": func.__doc__ or f"Execute {func_name}", - } - - except ImportError: - # Fallback to basic spec if ju.rjsf not available + func_to_form_spec = _import_func_to_form_spec() + if func_to_form_spec is None: return self._generate_basic_spec(func_name, func) + # ju nests the spec under a "rjsf" field by default; uf wants it flat + rjsf_config = {"nest_under_field": None, **self._rjsf_config} + form_spec = func_to_form_spec(func, **rjsf_config) + + # Apply custom UI schema factory if provided + if self._ui_schema_factory: + ui_schema = self._ui_schema_factory(func) + if "uiSchema" in form_spec: + form_spec["uiSchema"].update(ui_schema) + else: + form_spec["uiSchema"] = ui_schema + + return { + "schema": form_spec.get("schema", {}), + "uiSchema": form_spec.get("uiSchema", {}), + "func": func, + "name": func_name, + "description": func.__doc__ or f"Execute {func_name}", + } + def _generate_basic_spec(self, func_name: str, func: Callable) -> dict: """Generate a basic specification without ju.rjsf. - This is a fallback for when ju.rjsf is not available. It creates + This is the fallback used when ``ju`` is not installed. It creates a minimal JSON Schema from function signature. Args: