Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions tests/test_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
87 changes: 59 additions & 28 deletions uf/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
Loading