diff --git a/CHANGES.md b/CHANGES.md index 7bfc54df..60ef1f96 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,9 +13,9 @@ - Dropped subpackage `cuiman.gui.component`. - Added a pixi tool to demonstrate and debug generated UIs from OpenAPI Schema: `pixi run schema2ui`. - + - Enhanced the **Appligator** package with Dockerfile generation and - improved Airflow integration: + improved Airflow DAG generation: - Added `appligator.airflow.gen_dockerfile.generate` for Jinja2-template-based Dockerfile generation. Produces a two-stage pixi build with support for non-editable local package installs. The runtime base image is configurable diff --git a/gavicore/src/gavicore/models.py b/gavicore/src/gavicore/models.py index c92f6d00..c33cdef7 100644 --- a/gavicore/src/gavicore/models.py +++ b/gavicore/src/gavicore/models.py @@ -84,7 +84,6 @@ class Schema(BaseModel): example: Any | None = None examples: Any | None = None deprecated: bool | None = False - field_ref: str | None = Field(None, alias="$ref") # type "number" and "integer" minimum: int | float | None = None maximum: int | float | None = None @@ -116,11 +115,14 @@ class Schema(BaseModel): oneOf: list[Schema] | None = None anyOf: list[Schema] | None = None discriminator: Discriminator | None = None + # refs + id: str | None = Field(None, alias="$id", min_length=1) + ref: str | None = Field(None, alias="$ref", min_length=1) class Discriminator(BaseModel): - propertyName: str | None = Field(None, min_length=1) - mapping: dict[str, Schema] | None = None + propertyName: str = Field(..., min_length=1) + mapping: dict[str, str] | None = None # --------------------------------------------------------------------- diff --git a/gavicore/src/gavicore/ui/field/base.py b/gavicore/src/gavicore/ui/field/base.py index 874f1217..ab282b2c 100644 --- a/gavicore/src/gavicore/ui/field/base.py +++ b/gavicore/src/gavicore/ui/field/base.py @@ -3,22 +3,25 @@ # https://opensource.org/license/apache-2-0. from abc import ABC, abstractmethod -from typing import Any, TypeAlias +from typing import Generic, TypeVar + +from gavicore.util.ensure import ensure_type from ..vm import ViewModel from .meta import FieldMeta -View: TypeAlias = Any +VT = TypeVar("VT") """ A concrete piece of UI of typically data-bound UI (`view` such as widget, panel, control). """ -class Field(ABC): +class Field(ABC, Generic[VT]): """ - A binding unit between data (`view_model`) and a concrete piece - of typically data-bound UI (`view` such as widget, panel, control). + A binding unit between data, represented by property `view_model`, + and a concrete piece of typically data-bound UI, represented by the + `view` property of type `T`, such as a widget, panel, control. """ @property @@ -33,14 +36,21 @@ def view_model(self) -> ViewModel: @property @abstractmethod - def view(self) -> View: + def view(self) -> VT: """The view used by this field.""" -class FieldBase(Field, ABC): +FT = TypeVar("FT", bound=Field) +""" +A field type for a specific view type. +""" + + +class FieldBase(Field[VT], ABC, Generic[VT]): """Abstract base class for UI fields.""" - def __init__(self, view_model: ViewModel, view: View): + def __init__(self, view_model: ViewModel, view: VT): + ensure_type("view_model", view_model, ViewModel) self._view_model = view_model self._view = view self._bind() @@ -50,7 +60,7 @@ def view_model(self) -> ViewModel: return self._view_model @property - def view(self) -> View: + def view(self) -> VT: return self._view def _bind(self) -> None: diff --git a/gavicore/src/gavicore/ui/field/context.py b/gavicore/src/gavicore/ui/field/context.py index 7009534c..3644dd8b 100644 --- a/gavicore/src/gavicore/ui/field/context.py +++ b/gavicore/src/gavicore/ui/field/context.py @@ -2,20 +2,21 @@ # Permissions are hereby granted under the terms of the Apache 2.0 License: # https://opensource.org/license/apache-2-0. -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generic from gavicore.models import DataType, Schema -from gavicore.util.undefined import Undefined - -from ...util.ensure import ensure_condition -from ..vm import ( +from gavicore.ui.vm import ( + AnyViewModel, ArrayViewModel, NullableViewModel, ObjectViewModel, PrimitiveViewModel, ViewModel, ) -from .base import Field, View +from gavicore.util.ensure import ensure_condition +from gavicore.util.undefined import Undefined + +from .base import FT, VT from .meta import FieldMeta if TYPE_CHECKING: @@ -23,16 +24,17 @@ from .layout import LayoutFunction -class FieldContext: +class FieldContext(Generic[FT, VT]): """The context object passed to a UI field factory.""" def __init__( self, *, - generator: "FieldGenerator", + generator: "FieldGenerator[FT, VT]", meta: FieldMeta, initial_value: Any | Undefined = Undefined.value, - parent_ctx: "FieldContext | None" = None, + label_hidden: bool = False, + parent_ctx: "FieldContext[FT, VT] | None" = None, ): self._parent_ctx = parent_ctx self._generator = generator @@ -43,6 +45,7 @@ def __init__( if isinstance(initial_value, Undefined) else initial_value ) + self._label_hidden = label_hidden @property def meta(self) -> FieldMeta: @@ -54,6 +57,19 @@ def name(self) -> str: """The name from field metadata.""" return self._meta.name + @property + def label_hidden(self) -> bool: + """A flag indicating that the label for the field should not be shown.""" + return self._label_hidden + + @property + def label(self) -> str: + """ + A label for the field. + It is an empty string if the [label_hidden][label_hidden] flag is set. + """ + return "" if self._label_hidden else self._meta.label + @property def schema(self) -> Schema: """The OpenAPI Schema from field metadata.""" @@ -76,13 +92,13 @@ def path(self) -> list[str]: return self._parent_ctx.path + [self.name] return [self.name] - def layout(self, layout_function: "LayoutFunction", views: dict[str, View]) -> View: + def layout(self, layout_function: "LayoutFunction", views: dict[str, VT]) -> VT: """Lay out the given views using the field metadata's `layout` property.""" from .layout import LayoutManager return LayoutManager(layout_function, views).layout(self) - def create_property_fields(self) -> dict[str, Field]: + def create_property_fields(self) -> dict[str, FT]: """Create property fields given that this context's field is of type "object". """ @@ -97,7 +113,7 @@ def create_property_fields(self) -> dict[str, Field]: for prop_name, prop_meta in self.meta.properties.items() } - def create_item_field(self) -> Field: + def create_item_field(self) -> FT: """Create a new item field given that this context's field is of type "array". """ @@ -109,13 +125,17 @@ def create_item_field(self) -> Field: assert self.meta.items is not None return self.create_child_field(self.meta.items) - def create_child_field(self, child_meta: FieldMeta) -> Field: + def create_child_field( + self, child_meta: FieldMeta, label_hidden: bool = False + ) -> FT: """Create a new field for the given child field metadata.""" - child_ctx = self._create_child_ctx(child_meta) + child_ctx = self._create_child_ctx(child_meta, label_hidden=label_hidden) # noinspection PyProtectedMember return self._generator._generate_field(child_ctx) - def _create_child_ctx(self, child_meta: FieldMeta) -> "FieldContext": + def _create_child_ctx( + self, child_meta: FieldMeta, label_hidden: bool = False + ) -> "FieldContext[FT, VT]": initial_value = self.initial_value child_name = child_meta.name if ( @@ -130,6 +150,7 @@ def _create_child_ctx(self, child_meta: FieldMeta) -> "FieldContext": generator=self._generator, meta=child_meta, initial_value=child_value, + label_hidden=label_hidden, parent_ctx=self, ) @@ -143,6 +164,9 @@ class ViewModelFactory: def __init__(self, ctx: FieldContext): self._ctx = ctx + def any(self): + return AnyViewModel(self._ctx.meta, value=self._ctx.initial_value) + def primitive(self) -> PrimitiveViewModel: return PrimitiveViewModel(self._ctx.meta, value=self._ctx.initial_value) diff --git a/gavicore/src/gavicore/ui/field/factory.py b/gavicore/src/gavicore/ui/field/factory.py index 877675a4..ba520219 100644 --- a/gavicore/src/gavicore/ui/field/factory.py +++ b/gavicore/src/gavicore/ui/field/factory.py @@ -3,15 +3,16 @@ # https://opensource.org/license/apache-2-0. from abc import ABC, abstractmethod +from typing import Generic from gavicore.models import DataType -from .base import Field +from .base import FT from .context import FieldContext from .meta import FieldMeta -class FieldFactory(ABC): +class FieldFactory(ABC, Generic[FT]): """ Field factories are used to create UI fields from field metadata and other context data. @@ -29,12 +30,12 @@ def get_score(self, meta: FieldMeta) -> int: """ @abstractmethod - def create_field(self, ctx: FieldContext) -> Field: + def create_field(self, ctx: FieldContext) -> FT: """Create the UI field for the given field context.""" # noinspection PyMethodMayBeStatic,PyUnusedLocal -class FieldFactoryBase(FieldFactory, ABC): +class FieldFactoryBase(FieldFactory[FT], ABC, Generic[FT]): """ A convenient base class for UI field factories that create UI fields based on the schema data type. @@ -47,112 +48,161 @@ class FieldFactoryBase(FieldFactory, ABC): for which `get_{type}_score()` returns a value greater than zero. By default, all `create_{type}_field()` raise a `NotImplementedError`. - TODO: explain special treatment of nullable fields + Nullable fields (`ctx.schema.nullable`) are handled separately. """ def get_score(self, meta: FieldMeta) -> int: - """Get the score for a field based on its data type.""" + """ + Get the score for a field based on its data type. + + The default implementation delegates to various `get_xxx_score()` + methods that all return `0` when not being overridden. + + Note that we do not consider the case `not ctx.schema.required`, + for optional object properties. This must be dealt with in subclasses. + """ schema = meta.schema_ if schema.nullable: return self.get_nullable_score(meta) match schema.type: - case DataType.object: - return self.get_object_score(meta) - case DataType.array: - return self.get_array_score(meta) - case DataType.string: - return self.get_string_score(meta) - case DataType.number: - return self.get_number_score(meta) - case DataType.integer: - return self.get_integer_score(meta) case DataType.boolean: return self.get_boolean_score(meta) - case _: - return self.get_untyped_score(meta) + case DataType.integer: + return self.get_integer_score(meta) + case DataType.number: + return self.get_number_score(meta) + case DataType.string: + return self.get_string_score(meta) + case DataType.array: + return self.get_array_score(meta) + case DataType.object: + return self.get_object_score(meta) + if schema.oneOf: + return self.get_one_of_score(meta) + if schema.anyOf: + return self.get_any_of_score(meta) + if schema.allOf: + return self.get_all_of_score(meta) + return self.get_untyped_score(meta) - def get_object_score(self, meta: FieldMeta) -> int: - """Get the score for a field of type "object".""" + def get_nullable_score(self, meta: FieldMeta) -> int: + """Get the score for a nullable field.""" return 0 - def get_array_score(self, meta: FieldMeta) -> int: - """Get the score for a field of type "array".""" + def get_boolean_score(self, meta: FieldMeta) -> int: + """Get the score for a field of type "boolean".""" return 0 - def get_string_score(self, meta: FieldMeta) -> int: - """Get the score for a field of type "string".""" + def get_integer_score(self, meta: FieldMeta) -> int: + """Get the score for a field of type "integer".""" return 0 def get_number_score(self, meta: FieldMeta) -> int: """Get the score for a field of type "number".""" return 0 - def get_integer_score(self, meta: FieldMeta) -> int: - """Get the score for a field of type "integer".""" + def get_string_score(self, meta: FieldMeta) -> int: + """Get the score for a field of type "string".""" return 0 - def get_boolean_score(self, meta: FieldMeta) -> int: - """Get the score for a field of type "boolean".""" + def get_array_score(self, meta: FieldMeta) -> int: + """Get the score for a field of type "array".""" return 0 - def get_nullable_score(self, meta: FieldMeta) -> int: - """Get the score for a nullable field.""" + def get_object_score(self, meta: FieldMeta) -> int: + """Get the score for a field of type "object".""" + return 0 + + def get_one_of_score(self, meta: FieldMeta) -> int: + """Get the score for a schema that defines 'oneOf'.""" + return 0 + + def get_any_of_score(self, meta: FieldMeta) -> int: + """Get the score for a schema that defines 'anyOf'.""" + return 0 + + def get_all_of_score(self, meta: FieldMeta) -> int: + """Get the score for a schema that defines 'allOf'.""" return 0 def get_untyped_score(self, meta: FieldMeta) -> int: """Get the score for a field that has no explicit type.""" return 0 - def create_field(self, ctx: FieldContext) -> Field: - """Create a UI field based on its data type.""" - # TODO: we should also respect the case `not ctx.schema.required`, - # Check if this could be the treated as `ctx.schema.nullable`. - if ctx.schema.nullable: + def create_field(self, ctx: FieldContext) -> FT: + """ + Create a UI field based on its data type. + + The default implementation delegates to various `create_xxx_field()` + methods that all raise `NotImplementedError` when not being overridden. + + Note that we do not consider the case `not ctx.schema.required`, + for optional object properties. This must be dealt with in subclasses. + """ + schema = ctx.schema + if schema.nullable: return self.create_nullable_field(ctx) - match ctx.schema.type: - case DataType.object: - return self.create_object_field(ctx) - case DataType.array: - return self.create_array_field(ctx) - case DataType.string: - return self.create_string_field(ctx) - case DataType.number: - return self.create_number_field(ctx) - case DataType.integer: - return self.create_integer_field(ctx) + match schema.type: case DataType.boolean: return self.create_boolean_field(ctx) - case _: - return self.create_untyped_field(ctx) + case DataType.integer: + return self.create_integer_field(ctx) + case DataType.number: + return self.create_number_field(ctx) + case DataType.string: + return self.create_string_field(ctx) + case DataType.array: + return self.create_array_field(ctx) + case DataType.object: + return self.create_object_field(ctx) + if schema.oneOf is not None: + return self.create_one_of_field(ctx) + if schema.anyOf is not None: + return self.create_any_of_field(ctx) + if schema.allOf is not None: + return self.create_all_of_field(ctx) + return self.create_untyped_field(ctx) + + def create_nullable_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value that is nullable.""" + raise NotImplementedError - def create_object_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value of type "object".""" + def create_boolean_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value of type "boolean".""" raise NotImplementedError - def create_array_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value of type "array".""" + def create_integer_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value of type "integer".""" + raise NotImplementedError + + def create_number_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value of type "number".""" raise NotImplementedError - def create_string_field(self, ctx: FieldContext) -> Field: + def create_string_field(self, ctx: FieldContext) -> FT: """Create a UI field for a value of type "string".""" raise NotImplementedError - def create_number_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value of type "number".""" + def create_array_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value of type "array".""" raise NotImplementedError - def create_integer_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value of type "integer".""" + def create_object_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a value of type "object".""" raise NotImplementedError - def create_boolean_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value of type "boolean".""" + def create_one_of_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a schema that uses the "oneOf" operator.""" raise NotImplementedError - def create_nullable_field(self, ctx: FieldContext) -> Field: - """Create a UI field for a value that is nullable.""" + def create_any_of_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a schema that uses the "anyOf" operator.""" + raise NotImplementedError + + def create_all_of_field(self, ctx: FieldContext) -> FT: + """Create a UI field for a schema that uses the "allOf" operator.""" raise NotImplementedError - def create_untyped_field(self, ctx: FieldContext) -> Field: + def create_untyped_field(self, ctx: FieldContext) -> FT: """Create a UI field for a value with no explicit type specified.""" raise NotImplementedError diff --git a/gavicore/src/gavicore/ui/field/generator.py b/gavicore/src/gavicore/ui/field/generator.py index bf0ff142..fbcf24a3 100644 --- a/gavicore/src/gavicore/ui/field/generator.py +++ b/gavicore/src/gavicore/ui/field/generator.py @@ -2,31 +2,33 @@ # Permissions are hereby granted under the terms of the Apache 2.0 License: # https://opensource.org/license/apache-2-0. -from typing import Any, Callable +from typing import Any, Callable, Generic from gavicore.util.undefined import Undefined -from .base import Field +from .base import FT, VT from .context import FieldContext from .factory import FieldFactory from .meta import FieldMeta from .registry import FieldFactoryRegistry -class FieldGenerator: +class FieldGenerator(Generic[FT, VT]): """ Entry point for generating fields and field forms from field metadata. """ - def __init__(self, field_factory_registry: FieldFactoryRegistry | None = None): + def __init__(self, field_factory_registry: FieldFactoryRegistry[FT] | None = None): self._field_factory_registry = ( field_factory_registry if field_factory_registry is not None else FieldFactoryRegistry() ) - def register_field_factory(self, field_factory: FieldFactory) -> Callable[[], None]: + def register_field_factory( + self, field_factory: FieldFactory[FT] + ) -> Callable[[], None]: """ Register a new field factory. @@ -41,7 +43,7 @@ def generate_field( self, meta: FieldMeta, initial_value: Any | Undefined = Undefined.value, - ) -> Field: + ) -> FT: """ Generate a field or form from the given field metadata. @@ -59,12 +61,9 @@ def generate_field( ) return self._generate_field(ctx) - def _generate_field(self, ctx: FieldContext) -> Field: + def _generate_field(self, ctx: FieldContext[FT, VT]) -> FT: factory = self._field_factory_registry.lookup(ctx.meta) if factory is None: - # TODO: if a factory cannot be found, the default behaviour - # should be to emit a warning and ignore the field, as this will - # be a quite common situation. raise ValueError( f"no factory found for creating a UI for field {'.'.join(ctx.path)!r}" ) diff --git a/gavicore/src/gavicore/ui/field/layout.py b/gavicore/src/gavicore/ui/field/layout.py index 9b6b6861..7eebeb7a 100644 --- a/gavicore/src/gavicore/ui/field/layout.py +++ b/gavicore/src/gavicore/ui/field/layout.py @@ -2,41 +2,43 @@ # Permissions are hereby granted under the terms of the Apache 2.0 License: # https://opensource.org/license/apache-2-0. -from typing import Literal, Protocol +from typing import Generic, Literal, Protocol from gavicore.models import DataType from gavicore.util.ensure import ensure_callable, ensure_condition, ensure_type -from .base import Field, View +from .base import FT, VT from .context import FieldContext from .meta import FieldGroup -class LayoutFunction(Protocol): +class LayoutFunction(Protocol[FT, VT]): """Lay out given child views and return a new view.""" def __call__( self, - ctx: FieldContext, + ctx: FieldContext[FT, VT], direction: Literal["row", "column"], - child_views: list[View], - ) -> View: ... + child_views: list[VT], + ) -> VT: ... -class LayoutManager: +class LayoutManager(Generic[FT, VT]): """ Generator for nested layout fields for a parent field of type "object". """ def __init__( - self, layout_function: LayoutFunction, property_views: dict[str, View] + self, + layout_function: LayoutFunction[FT, VT], + property_views: dict[str, VT], ): ensure_callable("layout_function", layout_function) ensure_type("property_views", property_views, dict) self._layout_function = layout_function self._property_views = property_views - def layout(self, ctx: FieldContext) -> View: + def layout(self, ctx: FieldContext[FT, VT]) -> VT: """ Generate a layout field for a value of type "object". """ @@ -57,11 +59,11 @@ def layout(self, ctx: FieldContext) -> View: def _layout_by_group( self, - ctx: FieldContext, + ctx: FieldContext[FT, VT], group: FieldGroup, - property_views: dict[str, View], - ) -> Field: - child_views: list[Field] + property_views: dict[str, VT], + ) -> VT: + child_views: list[VT] if not group.items: child_views = list(property_views.values()) property_views.clear() diff --git a/gavicore/src/gavicore/ui/field/meta.py b/gavicore/src/gavicore/ui/field/meta.py index a390f5dc..71d70d2f 100644 --- a/gavicore/src/gavicore/ui/field/meta.py +++ b/gavicore/src/gavicore/ui/field/meta.py @@ -15,6 +15,7 @@ OutputDescription, Schema, ) +from gavicore.util.ensure import ensure_condition UI_KEYS = ["x-ui", "ui", "xUI", "xUi"] UI_KEY_PREFIXES = [f"{k}:" for k in UI_KEYS] + [f"{k}-" for k in UI_KEYS] @@ -127,6 +128,15 @@ class FieldMeta(pydantic.BaseModel): items: "FieldMeta | None" = None """The metadata of array items. Set if schema type is "array".""" + one_of: "list[FieldMeta] | None" = None + """The metadata for a schema's "oneOf" element, if given.""" + + any_of: "list[FieldMeta] | None" = None + """The metadata for a schema's "anyOf" element, if given.""" + + all_of: "list[FieldMeta] | None" = None + """The metadata for a schema's "allOf" element, if given.""" + layout: FieldLayout | None = None """Hint to lay out the children of this field.""" @@ -185,10 +195,17 @@ class FieldMeta(pydantic.BaseModel): maximum: int | float | None = None """Maximum numeric value as used for slider widgets.""" - # TODO: better name `options`? + # TODO: rename into options enum: list[Any] | None = None """Enumeration used for the options of select widgets.""" + ref: str | None = None + """Set from schema reference property '$ref'. + Since we resolve schemas, the original $ref value is kept + so we can still use it to evaluate a schema's discriminator + mappings. + """ + @property def nullable(self) -> bool: """Whether this field is nullable.""" @@ -261,7 +278,60 @@ def from_schema( required: bool | None = None, ) -> "FieldMeta": """Create field metadata from an OpenAPI Schema.""" - return _ui_field_meta_from_schema(name, schema, required=required) + f = FieldMetaFactory(name, schema) + return f.create_field_meta(name, schema, required=required) + + @classmethod + def from_schemas( + cls, name: str, *schemas: Schema, required: bool | None = None + ) -> "FieldMeta": + ensure_condition(len(schemas) > 0, "missing field metadata to merge") + if len(schemas) == 1: + return cls.from_schema(name, schemas[0], required=required) + + data_type: Any | None = None + merged_properties: dict[str, Any] | None = None + merged_required: set[str] | None = None + schema_dicts = [_make_schema_dict(s) for s in schemas] + merged_schema_dict = {} + for schema_dict in schema_dicts: + if "type" in schema_dict: + t = schema_dict["type"] + if data_type is None: + data_type = t + if "required" in schema_dict: + r = schema_dict["required"] + assert isinstance(r, list) + if merged_required is None: + merged_required = set() + merged_required.update(r) + if "properties" in schema_dict: + p = schema_dict["properties"] + assert isinstance(p, dict) + if merged_properties is None: + merged_properties = {} + merged_properties.update(p) + merged_schema_dict.update(schema_dict) + if data_type is not None: + merged_schema_dict["type"] = data_type + if merged_properties is not None: + merged_schema_dict["properties"] = merged_properties + if merged_required is not None: + merged_schema_dict["required"] = list(merged_required) + return FieldMeta.from_schema( + name, + Schema(**merged_schema_dict), + required=required, + ) + + @classmethod + def from_field_metas( + cls, name: str, *metas: "FieldMeta", required: bool | None = None + ) -> "FieldMeta": + ensure_condition(len(metas) > 0, "missing field metadata to merge") + if len(metas) == 1: + return metas[0] + return cls.from_schemas(name, *(m.schema_ for m in metas), required=required) def to_non_nullable(self) -> "FieldMeta": """Create a non-nullable version of this field metadata.""" @@ -307,50 +377,129 @@ def _schema_from_input_description( return Schema(**schema_dict), min_items > 0 -def _ui_field_meta_from_schema( - name: str, - schema: Schema, - required: bool | None = None, -) -> FieldMeta: - schema_dict = _make_schema_dict(schema) - ui_props = _extract_ui_props_from_schema_dict(schema_dict) - required = ui_props.pop("required", required) - items: FieldMeta | None = None - properties: dict[str, FieldMeta] | None = None - if schema.type == DataType.array: - schema_items = schema.items - assert schema_items is None or isinstance(schema.items, Schema) - items = _ui_field_meta_from_schema( - f"{name}_item", - schema.items if isinstance(schema.items, Schema) else Schema(**{}), - required=schema.minItems is not None and schema.minItems > 0, +class FieldMetaFactory: + def __init__(self, root_name: str, root_schema: Schema) -> None: + self._root_name = root_name + self._root_schema = root_schema + self._resolved_metas: dict[str, FieldMeta] = {} + + def create_field_meta( + self, + name: str, + schema: Schema, + required: bool | None = None, + ) -> FieldMeta: + # create the field metadata stub first + if schema.ref is not None: + # is ref already resolved + if schema.ref in self._resolved_metas: + # Note that the returned field metadata may not be + # fully populated with metadata, see below + return self._resolved_metas[schema.ref] + resolved_schema = self.resolve_schema_ref(schema.ref) + meta = FieldMeta( + name=name, schema=resolved_schema, ref=schema.ref, required=required + ) + # We already register the field, although it is still a stub, + # in order to detect cycles early + self._resolved_metas[schema.ref] = meta + else: + meta = FieldMeta(name=name, schema=schema, required=required) + # then update the stub with all the other metadata + self._update_field_meta_from_schema(meta) + return meta + + def _update_field_meta_from_schema(self, meta: FieldMeta) -> None: + name = meta.name + schema = meta.schema_ + ui_props = _extract_ui_props_from_schema(schema) + required = ui_props.pop("required", meta.required) + items: FieldMeta | None = None + properties: dict[str, FieldMeta] | None = None + if schema.type == DataType.array: + schema_items = schema.items + assert schema_items is None or isinstance(schema.items, Schema) + items = self.create_field_meta( + f"{name}_item", + schema.items if isinstance(schema.items, Schema) else Schema(**{}), + required=schema.minItems is not None and schema.minItems > 0, + ) + elif schema.type == DataType.object: + required_set = set(schema.required) if schema.required else set() + properties = {} + if schema.properties: + for prop_name, prop_schema in schema.properties.items(): + # noinspection PyTypeChecker + properties[prop_name] = self.create_field_meta( + prop_name, prop_schema, required=(prop_name in required_set) + ) + + one_of = self._resolve_schemas(schema.oneOf, name_prefix="option_") + any_of = self._resolve_schemas(schema.anyOf, name_prefix="option_") + all_of = self._resolve_schemas(schema.allOf, name_prefix="part_") + + update = dict( + required=required, + properties=properties, + items=items, + one_of=one_of, + any_of=any_of, + all_of=all_of, + **ui_props, ) - elif schema.type == DataType.object: - required_set = set(schema.required) if schema.required else set() - properties = {} - if schema.properties: - for prop_name, prop_schema in schema.properties.items(): - properties[prop_name] = _ui_field_meta_from_schema( - prop_name, prop_schema, required=(prop_name in required_set) - ) - - return FieldMeta( - name=name, - schema=schema, - required=required, - properties=properties, - items=items, - **ui_props, - ) + for k, v in update.items(): + setattr(meta, k, v) + + def resolve_schema_ref(self, ref: str) -> Schema: + if not ref.startswith("#/"): + raise NotImplementedError( + "$refs must be document-relative (start with '#/')" + ) + # Note, ref will likely be "#/$defs/" + # or "#/components/schemas/". + # We therefore expect the schema definitions in + # { "$defs": {"": ...}} + # or + # { "components": {"schemas": {"": ...}}} + # which are found in the model_extra dict of the root schema. + path = ref[2:].split("/") + extras = self._root_schema.model_extra + assert isinstance(extras, dict) + s: dict[str, Any] = extras + for i, name in enumerate(path): + ensure_condition( + name in s, + f"invalid $ref value: {'/'.join(path[: i + 1])!r} does not exist", + ) + s = s[name] + ensure_condition( + isinstance(s, dict), + f"invalid $ref value: dict expected for {'/'.join(path[: i + 1])!r}, " + f"but got {type(s).__name__}", + exception_type=TypeError, + ) + return Schema(**s) + + def _resolve_schemas( + self, schemas: list[Schema] | None, name_prefix: str + ) -> list[FieldMeta] | None: + if schemas is not None: + assert isinstance(schemas, list) + return [ + self.create_field_meta(f"{name_prefix}{i}", s) + for i, s in enumerate(schemas) + ] + return None _EXCL_SCHEMA_PROPERTY_NAMES = {"properties", "items", "required"} _VALIDATION_META = FieldMeta(name="validation_meta", schema=Schema(**{})) -def _extract_ui_props_from_schema_dict( - schema_dict: dict[str, Any], +def _extract_ui_props_from_schema( + schema: Schema, ) -> dict[str, Any]: + schema_dict = _make_schema_dict(schema) ui_props: dict[str, Any] = {} # update properties in ui_dict by yet unset FieldMeta values _update_ui_props_from_schema_props(schema_dict, ui_props) @@ -439,7 +588,7 @@ def _get_initial_value(meta: FieldMeta) -> Any: k: m.get_initial_value() for k, m in properties.items() if k in required } case _: - # TODO: handle other cases here: oneOf, anyOf, allOf, discriminator + # TODO: handle other cases here: oneOf, anyOf, allOf # raise ValueError( # f"Unsupported untyped schema in field {meta.name!r}" # ) diff --git a/gavicore/src/gavicore/ui/field/registry.py b/gavicore/src/gavicore/ui/field/registry.py index 6ffe4bd2..bcf01b61 100644 --- a/gavicore/src/gavicore/ui/field/registry.py +++ b/gavicore/src/gavicore/ui/field/registry.py @@ -2,28 +2,29 @@ # Permissions are hereby granted under the terms of the Apache 2.0 License: # https://opensource.org/license/apache-2-0. -from typing import Callable +from typing import Callable, Generic from gavicore.util.ensure import ensure_type +from .base import FT from .factory import FieldFactory from .meta import FieldMeta -class FieldFactoryRegistry: +class FieldFactoryRegistry(Generic[FT]): """A registry of field factories.""" - def __init__(self, *factories: "FieldFactory"): + def __init__(self, *factories: "FieldFactory[FT]"): for i, f in enumerate(factories): ensure_type(f"factory[{i}]", f, FieldFactory) self._factories = set(factories) @property - def factories(self) -> set["FieldFactory"]: + def factories(self) -> set["FieldFactory[FT]"]: """The factories registered in this registry.""" return set(self._factories) - def register(self, factory: "FieldFactory") -> Callable[[], None]: + def register(self, factory: "FieldFactory[FT]") -> Callable[[], None]: """Register the given field factory. Args: @@ -39,11 +40,11 @@ def _unregister(): self._factories.add(factory) return _unregister - def unregister(self, factory: "FieldFactory") -> None: + def unregister(self, factory: "FieldFactory[FT]") -> None: """Unregister a given factory.""" self._factories.discard(factory) - def lookup(self, meta: FieldMeta) -> "FieldFactory | None": + def lookup(self, meta: FieldMeta) -> "FieldFactory[FT] | None": """Find a factory for the given field metadata.""" max_score: int = 0 best_factory: FieldFactory | None = None diff --git a/gavicore/src/gavicore/ui/providers/panel/factory.py b/gavicore/src/gavicore/ui/providers/panel/factory.py index ab8331d5..bec75939 100644 --- a/gavicore/src/gavicore/ui/providers/panel/factory.py +++ b/gavicore/src/gavicore/ui/providers/panel/factory.py @@ -8,17 +8,17 @@ import panel as pn -import gavicore.ui as gcui -import gavicore.ui.vm as gcvm from gavicore.models import DataType +from gavicore.ui import FieldContext, FieldFactoryBase, FieldMeta +from gavicore.ui.vm import SelectiveViewModel, ViewModel from gavicore.util.json import JsonDateCodec from gavicore.util.text import ArrayTextConverter, TextConverter from .field import PanelField from .widgets.array import ArrayEditor, ArrayWidget from .widgets.bbox import BBoxEditor +from .widgets.labeled import LabeledWidget from .widgets.nullable import NullableWidget -from .widgets.object import ObjectWidget _ARRAY_TEXT_CONVERTERS: dict[DataType, ArrayTextConverter] = { DataType.boolean: TextConverter.BooleanArray(), @@ -27,19 +27,16 @@ DataType.string: TextConverter.StringArray(), } -# TODO: handle type="discriminator" -# TODO: handle type="anyOf" -# TODO: handle type="allOf" -# TODO: handle type="oneOf" +class PanelFieldFactory(FieldFactoryBase[PanelField]): + def get_nullable_score(self, meta: FieldMeta) -> int: + return 5 -class PanelFieldFactory(gcui.FieldFactoryBase): - def get_nullable_score(self, meta: gcui.FieldMeta) -> int: - return 1 - - def create_nullable_field(self, ctx: gcui.FieldContext) -> gcui.Field: + def create_nullable_field(self, ctx: FieldContext) -> PanelField: non_nullable_meta = ctx.meta.to_non_nullable() - non_nullable_field = ctx.create_child_field(non_nullable_meta) + non_nullable_field = ctx.create_child_field( + non_nullable_meta, label_hidden=True + ) view_model = ctx.vm.nullable(non_nullable_field.view_model) return PanelField( view_model, @@ -50,39 +47,147 @@ def create_nullable_field(self, ctx: gcui.FieldContext) -> gcui.Field: ), ) - def get_object_score(self, meta: gcui.FieldMeta) -> int: - return 1 + def get_boolean_score(self, _meta: FieldMeta) -> int: + return 5 - def create_object_field(self, ctx: gcui.FieldContext) -> gcui.Field: - prop_fields = ctx.create_property_fields() - view_models = {k: f.view_model for k, f in prop_fields.items()} - view_model = ctx.vm.object(properties=view_models) - if ctx.meta.layout is not None: - inner_viewable = ctx.layout( - _layout_views, # type: ignore[arg-type] - {k: f.view for k, f in prop_fields.items()}, - ) + def create_boolean_field(self, ctx: FieldContext) -> PanelField: + view_model = ctx.vm.primitive() + if view_model.meta.widget == "switch": + view = pn.widgets.Switch(value=view_model.value, name=view_model.meta.label) else: - inner_viewable = pn.Column(*[f.view for f in prop_fields.values()]) + view = pn.widgets.Checkbox( + value=view_model.value, name=view_model.meta.label + ) + return PanelField(view_model, view) + + def get_integer_score(self, meta: FieldMeta) -> int: + return 5 + + def get_number_score(self, meta: FieldMeta) -> int: + return 5 + + def create_integer_field(self, ctx: FieldContext) -> PanelField: + view_model = ctx.vm.primitive() return PanelField( view_model, - ObjectWidget(name=view_model.meta.label, inner_viewable=inner_viewable), + self._create_numeric_view(view_model, label=ctx.label, is_int=True), ) - def get_array_score(self, meta: gcui.FieldMeta) -> int: + def create_number_field(self, ctx: FieldContext) -> PanelField: + view_model = ctx.vm.primitive() + return PanelField( + view_model, self._create_numeric_view(view_model, label=ctx.label) + ) + + @classmethod + def _create_numeric_view( + cls, + view_model: ViewModel, + *, + is_int: bool | None = None, + label: str | None = None, + ) -> pn.widgets.WidgetBase: + value = view_model.value + description = view_model.meta.description + widget_hint = view_model.meta.widget + minimum = view_model.meta.minimum + maximum = view_model.meta.maximum + enum = view_model.meta.enum + + if enum: + if widget_hint == "radio": + return pn.widgets.RadioBoxGroup(name=label, value=value, options=enum) + if widget_hint == "button": + return pn.widgets.RadioButtonGroup( + name=label, value=value, options=enum + ) + if widget_hint == "slider": + return pn.widgets.DiscreteSlider(name=label, value=value, options=enum) + if widget_hint in ("select", None): + return pn.widgets.Select(name=label, value=value, options=enum) + + if ( + widget_hint == "slider" + and isinstance(minimum, (int, float)) + and isinstance(maximum, (int, float)) + and minimum < maximum + ): + step: int | float = pow(10.0, int(math.log10(maximum - minimum)) - 1.0) + if is_int: + slider_cls = pn.widgets.IntSlider + step = round(step) + else: + slider_cls = pn.widgets.FloatSlider + return slider_cls( + name=label, + value=value, + start=minimum, + end=maximum, + step=step, + ) + + if is_int: + input_cls = pn.widgets.IntInput + else: + input_cls = pn.widgets.FloatInput + return input_cls(name=label, value=value, description=description) + + def get_string_score(self, meta: FieldMeta) -> int: + return 5 + + def create_string_field(self, ctx: FieldContext) -> PanelField: + view_model = ctx.vm.primitive() + value = view_model.value + label = view_model.meta.label + enum = view_model.meta.enum + description = view_model.meta.description + format_ = view_model.schema.format + widget_hint = ctx.meta.widget + if format_ == "date": + json_codec = JsonDateCodec() + date = json_codec.from_json(value) or datetime.date.today() + return PanelField( + view_model, + pn.widgets.DatePicker(name=label, value=date, description=description), + json_codec=json_codec, + ) + if enum is not None: + if widget_hint == "radio": + view = pn.widgets.RadioBoxGroup(name=label, value=value, options=enum) + elif widget_hint == "button": + view = pn.widgets.RadioButtonGroup( + name=label, value=value, options=enum, description=description + ) + else: + view = pn.widgets.Select( + name=label, value=value, options=enum, description=description + ) + elif widget_hint == "textarea": + view = pn.widgets.TextAreaInput( + name=label, value=value, description=description + ) + else: + view = pn.widgets.TextInput( + name=label, value=value, description=description + ) + return PanelField(view_model, view) + + def get_array_score(self, meta: FieldMeta) -> int: assert meta.items is not None item_type = meta.items.schema_.type if item_type is None: + # TODO: handle this case --> use JSON editor as fallback return 0 format_ = meta.schema_.format if format_ is not None and format_.lower() == "bbox": return 10 array_converter = _ARRAY_TEXT_CONVERTERS.get(item_type) if array_converter is not None: - return 1 + return 5 + # TODO: handle this case --> use JSON editor as fallback return 0 - def create_array_field(self, ctx: gcui.FieldContext) -> gcui.Field: + def create_array_field(self, ctx: FieldContext) -> PanelField: view_model = ctx.vm.array() assert ctx.meta.items is not None @@ -108,139 +213,152 @@ def create_array_field(self, ctx: gcui.FieldContext) -> gcui.Field: value=view_model.value, array_converter=array_converter, separator=view_model.meta.separator, - name=view_model.meta.label, + name=ctx.label, description=view_model.meta.description, ) else: - def create_item_field(_index: int, value: Any) -> pn.widgets.WidgetBase: - assert isinstance(ctx.meta.items, gcui.FieldMeta) + def create_item_editor(_index: int, value: Any) -> pn.widgets.WidgetBase: + assert isinstance(ctx.meta.items, FieldMeta) ctx.meta.items.title = "" # Supress label for items item_field = ctx.create_item_field() item_field.view_model.value = value return item_field.view view = ArrayEditor( - name=view_model.meta.label, + name=ctx.label, value=view_model.value, - item_editor_factory=create_item_field, + item_editor_factory=create_item_editor, item_value_factory=ctx.meta.items.get_initial_value, ) return PanelField(view_model, view) - def get_string_score(self, meta: gcui.FieldMeta) -> int: - return 1 + def get_object_score(self, meta: FieldMeta) -> int: + return 5 - def create_string_field(self, ctx: gcui.FieldContext) -> gcui.Field: - view_model = ctx.vm.primitive() - value = view_model.value - label = view_model.meta.label - enum = view_model.meta.enum - description = view_model.meta.description - format_ = view_model.schema.format - if format_ == "date": - json_codec = JsonDateCodec() - date = json_codec.from_json(value) or datetime.date.today() - return PanelField( - view_model, - pn.widgets.DatePicker(name=label, value=date, description=description), - json_codec=json_codec, - ) - if enum is not None: - # TODO: check widget == "radio" - view = pn.widgets.Select( - name=label, - options=enum, - value=value, - description=description, - ) - elif ctx.meta.widget == "textarea": - view = pn.widgets.TextAreaInput( - name=label, value=value, description=description + def create_object_field(self, ctx: FieldContext) -> PanelField: + prop_fields = ctx.create_property_fields() + view_models = {k: f.view_model for k, f in prop_fields.items()} + view_model = ctx.vm.object(properties=view_models) + if ctx.meta.layout is not None: + inner_viewable = ctx.layout( + _layout_views, # type: ignore[arg-type] + {k: f.view for k, f in prop_fields.items()}, ) else: - view = pn.widgets.TextInput( - name=label, value=value, description=description + inner_viewable = _layout_views( + ctx, + "column", + [f.view for f in prop_fields.values()], # type: ignore[arg-type] ) - return PanelField(view_model, view) + return PanelField( + view_model, + LabeledWidget(inner_viewable, name=ctx.label, divider=True), + ) - def get_integer_score(self, meta: gcui.FieldMeta) -> int: - return 1 + def get_one_of_score(self, meta: FieldMeta) -> int: + return 5 + + def get_any_of_score(self, meta: FieldMeta) -> int: + return 5 + + def create_one_of_field(self, ctx: FieldContext) -> PanelField: + assert ctx.meta.one_of is not None + return self._create_one_of_field(ctx, ctx.meta.one_of) + + def create_any_of_field(self, ctx: FieldContext) -> PanelField: + assert ctx.meta.any_of is not None + return self._create_one_of_field(ctx, ctx.meta.any_of) + + def _create_one_of_field( + self, ctx: FieldContext, options: list[FieldMeta] + ) -> PanelField: + # handle degenerated oneOf/anyOf cases + match len(options): + case 0: + return self.create_untyped_field(ctx) + case 1: + field = ctx.create_child_field( + options[0], label_hidden=ctx.label_hidden + ) + assert isinstance(field, PanelField) + return field + + discriminator = ctx.schema.discriminator + if discriminator is not None: + child_fields = [ + ctx.create_child_field(option, label_hidden=True) + for option in options + if option.name != discriminator.propertyName + ] + else: + child_fields = [ + ctx.create_child_field(option, label_hidden=True) for option in options + ] + + view_model = SelectiveViewModel( + ctx.meta, + options=[f.view_model for f in child_fields], + discriminator=discriminator, + ) - def get_number_score(self, meta: gcui.FieldMeta) -> int: - return 1 + tab_options = [(f.meta.label, f.view) for f in child_fields] + tabs = pn.layout.Tabs(*tab_options) - def create_integer_field(self, ctx: gcui.FieldContext) -> gcui.Field: - view_model = ctx.vm.primitive() - return PanelField( - view_model, self._create_numeric_view(view_model, is_int=True) - ) + view_model.active_index = tabs.active - def create_number_field(self, ctx: gcui.FieldContext) -> gcui.Field: - view_model = ctx.vm.primitive() - return PanelField(view_model, self._create_numeric_view(view_model)) + def on_active_tab_change(_e): + view_model.active_index = tabs.active - @classmethod - def _create_numeric_view( - cls, view_model: gcvm.ViewModel, *, is_int: bool | None = None - ) -> pn.widgets.WidgetBase: - value = view_model.value - label = view_model.meta.label - description = view_model.meta.description - widget_hint = view_model.meta.widget - minimum = view_model.meta.minimum - maximum = view_model.meta.maximum - enum = view_model.meta.enum + tabs.param.watch(on_active_tab_change, "active") + view = LabeledWidget(tabs, name=ctx.label) + return PanelField(view_model, view) - if enum: - # TODO: check widget == "radio" - if widget_hint == "slider": - return pn.widgets.DiscreteSlider(name=label, value=value, options=enum) - if widget_hint in ("select", None): - return pn.widgets.Select(name=label, value=value, options=enum) + def get_all_of_score(self, meta: FieldMeta) -> int: + return 5 - if ( - widget_hint == "slider" - and isinstance(minimum, (int, float)) - and isinstance(maximum, (int, float)) - and minimum < maximum - ): - step: int | float = pow(10.0, int(math.log10(maximum - minimum)) - 1.0) - if is_int: - slider_cls = pn.widgets.IntSlider - step = round(step) - else: - slider_cls = pn.widgets.FloatSlider - return slider_cls( - name=label, - value=value, - start=minimum, - end=maximum, - step=step, - ) + def create_all_of_field(self, ctx: FieldContext) -> PanelField: + assert ctx.meta.all_of is not None - if is_int: - input_cls = pn.widgets.IntInput - else: - input_cls = pn.widgets.FloatInput - return input_cls(name=label, value=value, description=description) + parts = ctx.meta.all_of - def get_boolean_score(self, _meta: gcui.FieldMeta) -> int: - return 1 + # handle degenerated allOf cases + match len(parts): + case 0: + return self.create_untyped_field(ctx) + case 1: + field = ctx.create_child_field(parts[0], label_hidden=ctx.label_hidden) + assert isinstance(field, PanelField) + return field - def create_boolean_field(self, ctx: gcui.FieldContext) -> gcui.Field: - view_model = ctx.vm.primitive() - if view_model.meta.widget == "switch": - view = pn.widgets.Switch(value=view_model.value, name=view_model.meta.label) - else: - view = pn.widgets.Checkbox( - value=view_model.value, name=view_model.meta.label - ) - return PanelField(view_model, view) + combined_meta = FieldMeta.from_field_metas( + ctx.meta.name, *ctx.meta.all_of, required=ctx.meta.required + ) + child_field = ctx.create_child_field(combined_meta, label_hidden=True) + return PanelField( + child_field.view_model, + LabeledWidget(child_field.view, name=ctx.label, divider=True), + ) + + def get_untyped_score(self, meta: FieldMeta) -> int: + return 5 + + def create_untyped_field(self, ctx: FieldContext) -> PanelField: + json_editor = pn.widgets.JSONEditor( + value=ctx.initial_value, + width=300, + mode="text", + menu=False, + search=False, + ) + return PanelField( + ctx.vm.any(), + LabeledWidget(json_editor, name=ctx.label, divider=False), + ) def _layout_views( - _ctx: gcui.FieldContext, + _ctx: FieldContext, direction: Literal["row", "column"], views: list[pn.viewable.Viewable], ) -> pn.Row | pn.Column: diff --git a/gavicore/src/gavicore/ui/providers/panel/field.py b/gavicore/src/gavicore/ui/providers/panel/field.py index 9919a196..a7f3cb07 100644 --- a/gavicore/src/gavicore/ui/providers/panel/field.py +++ b/gavicore/src/gavicore/ui/providers/panel/field.py @@ -2,35 +2,41 @@ # Permissions are hereby granted under the terms of the Apache 2.0 License: # https://opensource.org/license/apache-2-0. -from typing import Any +from typing import Any, Final import panel as pn -import gavicore.ui as gcui -import gavicore.ui.vm as gcvm from gavicore.models import Schema +from gavicore.ui import FieldBase, FieldGenerator, FieldMeta +from gavicore.ui.vm import ViewModel +from gavicore.util.ensure import ensure_type from gavicore.util.json import JsonCodec, JsonIdentityCodec from gavicore.util.undefined import Undefined -class PanelField(gcui.FieldBase): +class PanelField(FieldBase[pn.widgets.WidgetBase]): """A panel widget-like field.""" + UNAVAILABLE_VIEW: Final = pn.widgets.ButtonIcon( + icon="mood-annoyed-2", name="Missing Field", disabled=True + ) + """The panel widget used to indicate that a view is missing.""" + def __init__( self, - view_model: gcvm.ViewModel, + view_model: ViewModel, view: pn.widgets.WidgetBase, *, json_codec: JsonCodec | None = None, ): - isinstance(view, pn.widgets.WidgetBase) + ensure_type("view", view, pn.widgets.WidgetBase) self._json_codec = json_codec or JsonIdentityCodec() super().__init__(view_model, view) @property def view(self) -> pn.widgets.WidgetBase: """The Panel widget-like viewable.""" - isinstance(self._view, pn.widgets.WidgetBase) + assert isinstance(self._view, pn.widgets.WidgetBase) return self._view def _bind(self): @@ -49,19 +55,19 @@ def from_schema( name: str, schema: Schema, initial_value: Any | Undefined = Undefined.value, - ) -> gcui.Field: + ) -> "PanelField": return cls.from_meta( - gcui.FieldMeta.from_schema(name, schema), initial_value=initial_value + FieldMeta.from_schema(name, schema), initial_value=initial_value ) @classmethod def from_meta( cls, - meta: gcui.FieldMeta, + meta: FieldMeta, initial_value: Any | Undefined = Undefined.value, - ) -> gcui.Field: + ) -> "PanelField": from .factory import PanelFieldFactory - generator = gcui.FieldGenerator() + generator = FieldGenerator[PanelField, pn.widgets.WidgetBase]() generator.register_field_factory(PanelFieldFactory()) return generator.generate_field(meta, initial_value=initial_value) diff --git a/gavicore/src/gavicore/ui/providers/panel/widgets/_util.py b/gavicore/src/gavicore/ui/providers/panel/widgets/_util.py index dd6b30e6..6ce0cd8f 100644 --- a/gavicore/src/gavicore/ui/providers/panel/widgets/_util.py +++ b/gavicore/src/gavicore/ui/providers/panel/widgets/_util.py @@ -5,8 +5,10 @@ import panel as pn -def get_header_items(title: str | None) -> tuple: +def get_header_items(title: str | None, divider: bool = False) -> tuple: if title: - return f"{title}", pn.layout.Divider(margin=(-16, 0, 0, 8)) - else: - return () + text = pn.widgets.StaticText(value=title) + if divider: + return text, pn.layout.Divider(margin=(-12, 0, 0, 8)) + return (text,) + return () diff --git a/gavicore/src/gavicore/ui/providers/panel/widgets/array.py b/gavicore/src/gavicore/ui/providers/panel/widgets/array.py index a03e626b..9b594176 100644 --- a/gavicore/src/gavicore/ui/providers/panel/widgets/array.py +++ b/gavicore/src/gavicore/ui/providers/panel/widgets/array.py @@ -92,7 +92,7 @@ def __init__( ) if self.name: panel = pn.Column( - *get_header_items(self.name), + *get_header_items(self.name, divider=True), self._items_box, self._add_row, ) diff --git a/gavicore/src/gavicore/ui/providers/panel/widgets/debug.py b/gavicore/src/gavicore/ui/providers/panel/widgets/debug.py deleted file mode 100644 index add5bc45..00000000 --- a/gavicore/src/gavicore/ui/providers/panel/widgets/debug.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) 2025-2026 by the Eozilla team and contributors -# Permissions are hereby granted under the terms of the Apache 2.0 License: -# https://opensource.org/license/apache-2-0. - -import contextlib -import io -import traceback - -import panel as pn - -pn.extension() - - -class DebugPanel(pn.widgets.WidgetBase, pn.custom.PyComponent): - """ - Replacement for `print()` to be used from Panel UI code. - - Panel seem to swallow all console output and IPython.display - outputs while a Panel viewable is showing. - """ - - _instance: "DebugPanel" - - @classmethod - def instance(cls) -> "DebugPanel": - if not hasattr(cls, "_instance"): - cls._instance = DebugPanel() - return cls._instance - - def __init__(self, **params): - super().__init__(**params) - self._buffer = [] - self._panel = pn.pane.Markdown("") - - def __panel__(self): - return self._panel - - def print(self, *args, end="\n"): - """Print something.""" - self._print(*args, end) - - def capture(self, func, *args, **kwargs): - """Capture stdout + exceptions.""" - buffer = io.StringIO() - # noinspection PyBroadException - try: - with contextlib.redirect_stdout(buffer): - result = func(*args, **kwargs) - except Exception: - buffer.write(traceback.format_exc()) - result = None - - output = buffer.getvalue() - if output: - self.print(output.strip()) - - return result - - def clear(self): - """Clear all panel contents.""" - self._buffer.clear() - self._update_panel() - - def _print(self, *args): - msg = " ".join(str(a) for a in args) - self._buffer.append(msg) - self._update_panel() - - def _update_panel(self): - self._panel.object = "".join(self._buffer[-1000:]) # keep last N lines diff --git a/gavicore/src/gavicore/ui/providers/panel/widgets/object.py b/gavicore/src/gavicore/ui/providers/panel/widgets/labeled.py similarity index 59% rename from gavicore/src/gavicore/ui/providers/panel/widgets/object.py rename to gavicore/src/gavicore/ui/providers/panel/widgets/labeled.py index 315921d0..eb3edaa2 100644 --- a/gavicore/src/gavicore/ui/providers/panel/widgets/object.py +++ b/gavicore/src/gavicore/ui/providers/panel/widgets/labeled.py @@ -3,30 +3,36 @@ # https://opensource.org/license/apache-2-0. import panel as pn +import param from ._util import get_header_items pn.extension() -class ObjectWidget(pn.widgets.WidgetBase, pn.custom.PyComponent): +class LabeledWidget(pn.widgets.WidgetBase, pn.custom.PyComponent): """ - A widget that provides a UI that renders the views - of an object's properties. + A widget that provides a UI that will always render the + given inner widget with a label given by the widget's name, + if any. Used for widgets that doesn't support labels. """ + divider = param.Boolean(default=False) + def __init__( self, inner_viewable: pn.viewable.Viewable, + divider: bool = False, **params, ): super().__init__(**params) + self.divider = divider self.inner_viewable = inner_viewable def __panel__(self): if self.name: return pn.Column( - *get_header_items(self.name), + *get_header_items(self.name, divider=self.divider), self.inner_viewable, ) else: diff --git a/gavicore/src/gavicore/ui/vm/__init__.py b/gavicore/src/gavicore/ui/vm/__init__.py index 3b1844af..d6742e4f 100644 --- a/gavicore/src/gavicore/ui/vm/__init__.py +++ b/gavicore/src/gavicore/ui/vm/__init__.py @@ -8,13 +8,17 @@ from .nullable import NullableViewModel from .object import ObjectViewModel from .primitive import PrimitiveViewModel +from .selective import SelectiveViewModel +from .some import AnyViewModel __all__ = [ + "AnyViewModel", "ArrayViewModel", "CompositeViewModel", "NullableViewModel", "ObjectViewModel", "PrimitiveViewModel", + "SelectiveViewModel", "ViewModel", "ViewModelChangeEvent", "ViewModelObserver", diff --git a/gavicore/src/gavicore/ui/vm/base.py b/gavicore/src/gavicore/ui/vm/base.py index ec4e07de..a197d037 100644 --- a/gavicore/src/gavicore/ui/vm/base.py +++ b/gavicore/src/gavicore/ui/vm/base.py @@ -5,14 +5,14 @@ from abc import ABC, abstractmethod from collections.abc import Generator from contextlib import contextmanager -from typing import Any, Callable, Generic, Protocol, TypeVar - -from bokeh.core.property.singletons import UndefinedType +from typing import TYPE_CHECKING, Any, Callable, Generic, Protocol, TypeVar from gavicore.models import DataType, Schema -from gavicore.util.undefined import UNDEFINED +from gavicore.util.ensure import ensure_type +from gavicore.util.undefined import UNDEFINED, Undefined -from ..field.meta import FieldMeta +if TYPE_CHECKING: + from gavicore.ui.field import FieldMeta class ViewModelChangeEvent: @@ -38,20 +38,21 @@ def __call__(self, event: ViewModelChangeEvent) -> None: ... class ViewModel(Generic[T], ABC): """Abstract base class for all view models.""" - def __init__(self, meta: FieldMeta): + def __init__(self, meta: "FieldMeta"): """Base class constructor.""" - if not isinstance(meta, FieldMeta): - raise TypeError(f"meta must have type {FieldMeta.__name__}") + from gavicore.ui.field import FieldMeta + + ensure_type("meta", meta, FieldMeta) self._meta = meta self._observers: set[ViewModelObserver] = set() @classmethod - def create( - cls, meta: FieldMeta, *, value: Any | UndefinedType = UNDEFINED + def from_field_meta( + cls, meta: "FieldMeta", *, value: Any | Undefined = UNDEFINED ) -> "ViewModel": """ - Create a new view model instance for the given field metadata - and initial value. + Create a new view model instance from the given field metadata + and an optional initial value. """ schema = meta.schema_ @@ -78,7 +79,7 @@ def create( raise ValueError(f"missing type in schema for field {meta.name!r}") @property - def meta(self) -> FieldMeta: + def meta(self) -> "FieldMeta": """The field's metadata.""" return self._meta diff --git a/gavicore/src/gavicore/ui/vm/composite.py b/gavicore/src/gavicore/ui/vm/composite.py index e776c3b5..7eee6a25 100644 --- a/gavicore/src/gavicore/ui/vm/composite.py +++ b/gavicore/src/gavicore/ui/vm/composite.py @@ -73,7 +73,7 @@ def __setitem__(self, key: K, value: Any) -> None: """Set item by key and value.""" def _create_child(self, child_meta: FieldMeta, child_value: Any) -> ViewModel: - child_vm = self.create(child_meta, value=child_value) + child_vm = self.from_field_meta(child_meta, value=child_value) child_vm.watch(self._on_child_change) return child_vm diff --git a/gavicore/src/gavicore/ui/vm/nullable.py b/gavicore/src/gavicore/ui/vm/nullable.py index 9e4013a5..9455d9fb 100644 --- a/gavicore/src/gavicore/ui/vm/nullable.py +++ b/gavicore/src/gavicore/ui/vm/nullable.py @@ -36,7 +36,7 @@ def __init__( self._inner.watch(self._on_inner_change) else: non_nullable_meta = meta.to_non_nullable() - self._inner = self.create( + self._inner = self.from_field_meta( non_nullable_meta, value=( non_nullable_meta.get_initial_value() diff --git a/gavicore/src/gavicore/ui/vm/object.py b/gavicore/src/gavicore/ui/vm/object.py index 6177dcfa..17911a86 100644 --- a/gavicore/src/gavicore/ui/vm/object.py +++ b/gavicore/src/gavicore/ui/vm/object.py @@ -4,9 +4,10 @@ from typing import Any +from gavicore.ui.field.meta import FieldMeta +from gavicore.util.ensure import ensure_type from gavicore.util.undefined import Undefined -from ..field.meta import FieldMeta from .base import ViewModel from .composite import CompositeViewModel @@ -62,17 +63,12 @@ def __init__( value: Any | Undefined = Undefined.value, properties: dict[str, ViewModel] | None = None, ): + ensure_type("meta.properties", meta.properties, (dict, type(None))) super().__init__(meta, value) - assert isinstance(meta.properties, dict) # initialize item view models - for k, child_meta in meta.properties.items(): + for k, child_meta in (meta.properties or {}).items(): vm = properties.get(k) if properties else None if vm is not None: - if vm.meta is not child_meta: - raise ValueError( - f"invalid view model passed for property {k!r} " - f"of field {meta.name!r}" - ) vm.watch(self._on_child_change) else: if isinstance(value, dict) and k in value: diff --git a/gavicore/src/gavicore/ui/vm/selective.py b/gavicore/src/gavicore/ui/vm/selective.py new file mode 100644 index 00000000..0733076f --- /dev/null +++ b/gavicore/src/gavicore/ui/vm/selective.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026 by the Eozilla team and contributors +# Permissions are hereby granted under the terms of the Apache 2.0 License: +# https://opensource.org/license/apache-2-0. + +from typing import Any + +from gavicore.models import Discriminator +from gavicore.ui.field.meta import FieldMeta +from gavicore.util.ensure import ensure_condition, ensure_type + +from .base import ViewModel, ViewModelChangeEvent + + +class _InvDiscriminator: + """ + An inverse discriminator that maps option indexes + to the value of an original discriminator's mapping. + """ + + def __init__(self, name: str, mapping: dict[int, str]): + self.name = name + self.mapping = mapping + + @classmethod + def from_discriminator(cls, discriminator: Discriminator, options: list[ViewModel]): + mapping: dict[int, str] = {} + if discriminator.mapping is None: + for i, option in enumerate(options): + mapping[i] = cls.require_option_ref(option).rsplit("/", maxsplit=1)[-1] + else: + for i, option in enumerate(options): + o_ref: str = cls.require_option_ref(option) + value: str | None = None + for map_name, map_ref in discriminator.mapping.items(): + if map_ref == o_ref: + value = map_name + break + ensure_condition( + value is not None, + f"invalid discriminator mapping: " + f"missing value for reference {o_ref!r}", + ) + assert value is not None + mapping[i] = value + no = len(options) + nm = len(mapping) + ensure_condition( + no == nm, + f"invalid discriminator mapping: " + f"too {'many' if nm > no else 'few'} entries", + ) + return _InvDiscriminator(discriminator.propertyName, mapping) + + @classmethod + def require_option_ref(cls, option: ViewModel) -> str: + option_ref = option.meta.ref + ensure_condition( + isinstance(option_ref, str), + f"option {option.meta.name!r} should be a reference", + ) + assert option_ref is not None + return option_ref + + +class SelectiveViewModel(ViewModel[Any]): + """ + A view model for values that are selected + from a list of possible options. + """ + + def __init__( + self, + meta: FieldMeta, + options: list[ViewModel], + active_index: int = 0, + discriminator: Discriminator | None = None, + ): + ensure_type("meta", meta, FieldMeta) + ensure_type("options", options, list) + ensure_type("active_index", active_index, int) + ensure_condition( + 0 <= active_index < len(options), + "active_index is out of bounds", + ) + super().__init__(meta) + self._options = list(options) + self._active_index = active_index + self._unwatch_options = [ + option.watch(self._on_option_change) for option in options + ] + if discriminator is not None: + self._inv_discriminator = _InvDiscriminator.from_discriminator( + discriminator, options + ) + else: + self._inv_discriminator = None + + @property + def active_index(self) -> int: + return self._active_index + + @active_index.setter + def active_index(self, active_index: int) -> None: + ensure_condition( + 0 <= active_index < len(self._options), "active_index out of bounds" + ) + if active_index != self._active_index: + self._active_index = active_index + self._notify() + + def dispose(self): + for unwatch_option in self._unwatch_options: + unwatch_option() + self._unwatch_options = [] + super().dispose() + + def _on_option_change(self, event: ViewModelChangeEvent) -> None: + self._notify(*event.causes) + + def _get_value(self) -> Any: + active_index = self._active_index + assert 0 <= active_index < len(self._options) + value = self._options[active_index].value + discriminator = self._inv_discriminator + if discriminator is not None and isinstance(value, dict): + discriminator_value = discriminator.mapping[active_index] + if value.get(discriminator.name) != discriminator_value: + value = dict(value) + value[discriminator.name] = discriminator_value + return value + + def _set_value(self, value: Any) -> None: + # Note, we expect that given value fits the option selected + # by active index. This means this class does not and cannot + # automatically set the right active index based on the value. + self._options[self._active_index].value = value diff --git a/gavicore/src/gavicore/ui/vm/some.py b/gavicore/src/gavicore/ui/vm/some.py new file mode 100644 index 00000000..ae38d721 --- /dev/null +++ b/gavicore/src/gavicore/ui/vm/some.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026 by the Eozilla team and contributors +# Permissions are hereby granted under the terms of the Apache 2.0 License: +# https://opensource.org/license/apache-2-0. + +from typing import Any + +from gavicore.util.undefined import Undefined + +from ..field.meta import FieldMeta +from .base import ViewModel + + +class AnyViewModel(ViewModel[Any]): + """ + A view model that can represent any value. + """ + + def __init__( + self, meta: FieldMeta, *, value: Any | Undefined = Undefined.value + ) -> None: + super().__init__(meta) + self._value = ( + value if Undefined.is_defined(value) else FieldMeta.get_initial_value(meta) + ) + + def _get_value(self) -> Any: + return self._value + + def _set_value(self, value: Any) -> None: + if value != self._value: + self._value = value + self._notify() diff --git a/gavicore/tests/ui/field/test_meta.py b/gavicore/tests/ui/field/test_meta.py index 19ecc7f1..4d852f0a 100644 --- a/gavicore/tests/ui/field/test_meta.py +++ b/gavicore/tests/ui/field/test_meta.py @@ -263,6 +263,114 @@ def test_object_schema_with_layout(self): meta.layout, ) + def test_schema_with_ref(self): + # top-level $ref + point_schema_dict = { + "type": "array", + "title": "A point", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + } + meta = FieldMeta.from_schema( + "x", + Schema( + **{ + "$ref": "#/$defs/Point", + "$defs": { + "Point": point_schema_dict, + }, + } + ), + ) + self.assertEqual("x", meta.name) + self.assertEqual(Schema(**point_schema_dict), meta.schema_) + self.assertEqual("#/$defs/Point", meta.ref) + self.assertEqual("A point", meta.title) + + def test_schema_with_nested_ref(self): + # top-level $ref + point_schema_dict = { + "type": "array", + "title": "A point", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + } + line_schema_dict = { + "type": "array", + "title": "A line", + "items": {"$ref": "#/$defs/Point"}, + "minItems": 2, + "maxItems": 2, + } + meta = FieldMeta.from_schema( + "x", + Schema( + **{ + "$ref": "#/$defs/Line", + "$defs": { + "Line": line_schema_dict, + "Point": point_schema_dict, + }, + } + ), + ) + self.assertEqual("x", meta.name) + self.assertEqual(Schema(**line_schema_dict), meta.schema_) + self.assertEqual("#/$defs/Line", meta.ref) + self.assertEqual("A line", meta.title) + self.assertIsInstance(meta.items, FieldMeta) + self.assertEqual(Schema(**point_schema_dict), meta.items.schema_) + self.assertEqual("#/$defs/Point", meta.items.ref) + + def test_schema_with_nested_ref_cycle(self): + # top-level $ref + file_schema_dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "content": {"type": "string", "format": "binary"}, + }, + } + folder_schema_dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "files": { + "type": "array", + "items": { + "oneOf": [ + {"$ref": "#/$defs/File"}, + {"$ref": "#/$defs/Folder"}, + ] + }, + }, + }, + } + meta = FieldMeta.from_schema( + "x", + Schema( + **{ + "$ref": "#/$defs/Folder", + "$defs": { + "File": file_schema_dict, + "Folder": folder_schema_dict, + }, + } + ), + ) + self.assertEqual("x", meta.name) + self.assertEqual(Schema(**folder_schema_dict), meta.schema_) + self.assertEqual("#/$defs/Folder", meta.ref) + self.assertIsInstance(meta.properties, dict) + files_meta = meta.properties.get("files") + self.assertIsInstance(files_meta, FieldMeta) + self.assertIsInstance(files_meta.items, FieldMeta) + self.assertIsInstance(files_meta.items.one_of, list) + folder_meta = files_meta.items.one_of[1] + self.assertIs(meta, folder_meta) + def test_input_precedence(self): self._assert_description( description_props={ diff --git a/gavicore/tests/ui/providers/panel/test_factory.py b/gavicore/tests/ui/providers/panel/test_factory.py index 89d97a48..e24c3f9a 100644 --- a/gavicore/tests/ui/providers/panel/test_factory.py +++ b/gavicore/tests/ui/providers/panel/test_factory.py @@ -14,7 +14,7 @@ def test_get_score_for_arrays(self): factory = PanelFieldFactory() for t in ("boolean", "integer", "number", "string"): self.assertEqual( - 1, + 5, factory.get_score( _meta_from_schema({"type": "array", "items": {"type": t}}) ), diff --git a/gavicore/tests/ui/providers/panel/test_field.py b/gavicore/tests/ui/providers/panel/test_field.py index 1cddfdf1..238cb351 100644 --- a/gavicore/tests/ui/providers/panel/test_field.py +++ b/gavicore/tests/ui/providers/panel/test_field.py @@ -4,8 +4,6 @@ from unittest import TestCase -import pytest - from gavicore.models import Schema from gavicore.ui import FieldMeta from gavicore.ui.providers.panel import PanelField @@ -16,31 +14,15 @@ # noinspection PyMethodMayBeStatic class PanelFieldTest(TestCase): def test_with_all_schemas(self): - # TODO: test type="discriminator" - # TODO: test type="anyOf" - # TODO: test type="allOf" - # TODO: test type="oneOf" - schemas = load_schemas() for path, schema in schemas: name = path.stem.replace("-", "_") try: PanelField.from_schema(name, schema) except Exception as e: - import traceback - - print(80 * "-") - traceback.print_exc() - print(80 * "-") - self.fail( - f"Exception for schema {path.name!r}: {type(e).__name__}: {e}" - ) - - def test_empty_schema(self): - with pytest.raises( - ValueError, match="no factory found for creating a UI for field 'root'" - ): - PanelField.from_meta(_meta_from_schema({})) + raise self.failureException( + f"Exception for schema {path.name}: {e}" + ) from e def _meta_from_schema(schema: Schema | dict) -> FieldMeta: diff --git a/gavicore/tests/ui/schemas/any.yaml b/gavicore/tests/ui/schemas/any.yaml new file mode 100644 index 00000000..bcac1e6c --- /dev/null +++ b/gavicore/tests/ui/schemas/any.yaml @@ -0,0 +1,4 @@ +type: object +properties: + any_value: + description: This can be any valid JSON value diff --git a/gavicore/tests/ui/schemas/combinations.yaml b/gavicore/tests/ui/schemas/combinations.yaml new file mode 100644 index 00000000..b487f3ce --- /dev/null +++ b/gavicore/tests/ui/schemas/combinations.yaml @@ -0,0 +1,79 @@ +type: object +properties: + one_of: + oneOf: + - type: object + title: S3 Object + properties: + bucket: + type: string + object: + type: string + access_key_id: + type: string + secret_access_key: + type: string + - type: object + title: File Upload + properties: + local_path: + type: string + "x-ui:widget": fileupload + - type: object + title: Inline Value + properties: + json_value: + type: string + "x-ui:widget": textarea + + any_of: + anyOf: + - type: object + title: S3 Object + properties: + bucket: + type: string + object: + type: string + access_key_id: + type: string + secret_access_key: + type: string + - type: object + title: File Upload + properties: + local_path: + type: string + "x-ui:widget": fileupload + - type: object + title: Inline Value + properties: + json_value: + type: string + "x-ui:widget": textarea + + all_of: + allOf: + - type: object + title: S3 Object + properties: + bucket: + type: string + object: + type: string + access_key_id: + type: string + secret_access_key: + type: string + - type: object + title: File Upload + properties: + local_path: + type: string + "x-ui:widget": fileupload + - type: object + title: Inline Value + properties: + json_value: + type: string + "x-ui:widget": textarea diff --git a/gavicore/tests/ui/schemas/discriminator.yaml b/gavicore/tests/ui/schemas/discriminator.yaml new file mode 100644 index 00000000..3cc1821e --- /dev/null +++ b/gavicore/tests/ui/schemas/discriminator.yaml @@ -0,0 +1,69 @@ +type: object +properties: + discriminator_without_mapping: + oneOf: + - $ref: "#/components/schemas/Point" + - $ref: "#/components/schemas/LineString" + - $ref: "#/components/schemas/Polygon" + discriminator: + propertyName: type + + discriminator_with_mapping: + oneOf: + - $ref: "#/components/schemas/Point" + - $ref: "#/components/schemas/LineString" + - $ref: "#/components/schemas/Polygon" + discriminator: + propertyName: type + mapping: + pt: "#/components/schemas/Point" + ls: "#/components/schemas/LineString" + pg: "#/components/schemas/Polygon" + +components: + schemas: + Coordinate: + items: + type: number + maxItems: 3 + minItems: 2 + type: array + LineString: + properties: + coordinates: + items: + $ref: '#/components/schemas/Coordinate' + minItems: 2 + type: array + type: + type: string + required: + - type + - coordinates + type: object + Point: + properties: + coordinates: + $ref: '#/components/schemas/Coordinate' + type: + type: string + required: + - type + - coordinates + type: object + Polygon: + properties: + coordinates: + items: + items: + $ref: '#/components/schemas/Coordinate' + minItems: 3 + type: array + minItems: 1 + type: array + type: + type: string + required: + - type + - coordinates + type: object diff --git a/gavicore/tests/ui/schemas/number.yaml b/gavicore/tests/ui/schemas/number.yaml index 2719c99b..b4d672f5 100644 --- a/gavicore/tests/ui/schemas/number.yaml +++ b/gavicore/tests/ui/schemas/number.yaml @@ -5,6 +5,14 @@ properties: select: type: number enum: [0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0] + radio: + type: number + enum: [1.0, 5.0, 10.0, 50.0, 100.0] + "x-ui:widget": radio + button: + type: number + enum: [1.0, 5.0, 10.0, 50.0, 100.0] + "x-ui:widget": button slider: type: number minimum: 0.0 diff --git a/gavicore/tests/ui/schemas/string.yaml b/gavicore/tests/ui/schemas/string.yaml index 954429cb..151878d9 100644 --- a/gavicore/tests/ui/schemas/string.yaml +++ b/gavicore/tests/ui/schemas/string.yaml @@ -14,6 +14,23 @@ properties: select: type: string enum: - - green - - blue - - yellow \ No newline at end of file + - yellow + - red + - green + - blue + radio: + type: string + enum: + - yellow + - red + - green + - blue + "x-ui:widget": "radio" + button: + type: string + enum: + - yellow + - red + - green + - blue + "x-ui:widget": "button" diff --git a/gavicore/tests/ui/vm/test_selective.py b/gavicore/tests/ui/vm/test_selective.py new file mode 100644 index 00000000..9c7d70ce --- /dev/null +++ b/gavicore/tests/ui/vm/test_selective.py @@ -0,0 +1,214 @@ +# Copyright (c) 2026 by the Eozilla team and contributors +# Permissions are hereby granted under the terms of the Apache 2.0 License: +# https://opensource.org/license/apache-2-0. + +from unittest import TestCase + +import pytest + +from gavicore.models import Schema +from gavicore.ui import FieldMeta +from gavicore.ui.vm import ( + SelectiveViewModel, + ViewModel, +) +from gavicore.ui.vm.base import ViewModelChangeRecorder + + +class SelectiveViewModelTest(TestCase): + def setUp(self): + self.option_schemas = [ + Schema(**{"type": "boolean"}), + Schema(**{"type": "integer", "default": 137}), + Schema(**{"type": "string", "default": "/data"}), + ] + self.meta = FieldMeta.from_schema( + "x", + Schema(**{"oneOf": self.option_schemas}), + ) + self.options = [ + ViewModel.from_field_meta(FieldMeta.from_schema(f"option_{i}", s)) + for i, s in enumerate(self.option_schemas) + ] + + def test_value_access_ok(self): + vm = SelectiveViewModel(self.meta, options=self.options) + + observer = ViewModelChangeRecorder() + vm.watch(observer) + + self.assertEqual(0, vm.active_index) + self.assertEqual(False, vm.value) + + vm.active_index = 1 + self.assertEqual(1, vm.active_index) + self.assertEqual(137, vm.value) + self.assertEqual(1, len(observer.change_events)) + + vm.active_index = 2 + self.assertEqual(2, vm.active_index) + self.assertEqual("/data", vm.value) + self.assertEqual(2, len(observer.change_events)) + + vm.value = "s3://xc-data" + self.assertEqual(2, vm.active_index) + self.assertEqual("s3://xc-data", vm.value) + self.assertEqual(3, len(observer.change_events)) + + vm.dispose() + self.assertEqual(set(), vm._observers) + for option in self.options: + self.assertEqual(set(), option._observers) + + def test_ctor_fails(self): + with pytest.raises( + TypeError, match="meta must have type FieldMeta, but was int" + ): + # noinspection PyTypeChecker + SelectiveViewModel(12, options=[]) + + with pytest.raises(TypeError, match="options must have type list, but was int"): + # noinspection PyTypeChecker + SelectiveViewModel(self.meta, options=13) + + with pytest.raises( + TypeError, match="active_index must have type int, but was NoneType" + ): + # noinspection PyTypeChecker + SelectiveViewModel(self.meta, options=self.options, active_index=None) + + with pytest.raises(ValueError, match="active_index is out of bounds"): + SelectiveViewModel(self.meta, options=self.options, active_index=3) + + +definitions = { + "components": { + "schemas": { + "Point": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "coordinates": {"$ref": "#/components/schemas/Coordinate"}, + }, + "required": ["type", "coordinates"], + }, + "LineString": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "coordinates": { + "type": "array", + "items": {"$ref": "#/components/schemas/Coordinate"}, + "minItems": 2, + }, + }, + "required": ["type", "coordinates"], + }, + "Polygon": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "coordinates": { + "type": "array", + "items": { + "type": "array", + "items": {"$ref": "#/components/schemas/Coordinate"}, + "minItems": 3, + }, + "minItems": 1, + }, + }, + "required": ["type", "coordinates"], + }, + "Coordinate": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 3, + }, + } + }, +} + +import yaml + +with open("discriminator.yaml", "w") as f: + yaml.safe_dump(definitions, f) + + +class SelectiveViewModelWithDiscriminatorTest(TestCase): + @classmethod + def create_field_meta( + cls, mapping: dict | None + ) -> tuple[FieldMeta, list[ViewModel]]: + options = [ + {"$ref": "#/components/schemas/Point"}, + {"$ref": "#/components/schemas/LineString"}, + {"$ref": "#/components/schemas/Polygon"}, + ] + meta = FieldMeta.from_schema( + "x", + Schema( + **{ + "oneOf": options, + "discriminator": { + "propertyName": "type", + **({"mapping": mapping} if mapping else {}), + }, + **definitions, + } + ), + ) + options = [ViewModel.from_field_meta(m) for i, m in enumerate(meta.one_of)] + return meta, options + + def test_value_access_without_mapping(self): + self._assert_discriminator_works( + mapping=None, expected_values=["Point", "LineString", "Polygon"] + ) + + def test_value_access_with_mapping(self): + self._assert_discriminator_works( + mapping={ + "ls": "#/components/schemas/LineString", + "pg": "#/components/schemas/Polygon", + "pt": "#/components/schemas/Point", + }, + expected_values=["pt", "ls", "pg"], + ) + + def _assert_discriminator_works( + self, mapping: dict | None, expected_values: list[str] + ): + meta, options = self.create_field_meta(mapping=mapping) + + vm = SelectiveViewModel( + meta, options=options, discriminator=meta.schema_.discriminator + ) + + observer = ViewModelChangeRecorder() + vm.watch(observer) + + self.assertEqual(0, vm.active_index) + self.assertEqual( + {"type": expected_values[0], "coordinates": [0.0, 0.0]}, vm.value + ) + + vm.active_index = 1 + self.assertEqual(1, vm.active_index) + self.assertEqual( + {"type": expected_values[1], "coordinates": [[0.0, 0.0], [0.0, 0.0]]}, + vm.value, + ) + self.assertEqual(1, len(observer.change_events)) + + vm.active_index = 2 + self.assertEqual(2, vm.active_index) + self.assertEqual( + { + "type": expected_values[2], + "coordinates": [[[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]], + }, + vm.value, + ) + self.assertEqual(2, len(observer.change_events)) diff --git a/gavicore/tests/ui/vm/test_vm.py b/gavicore/tests/ui/vm/test_vm.py index a8899141..8c21b04f 100644 --- a/gavicore/tests/ui/vm/test_vm.py +++ b/gavicore/tests/ui/vm/test_vm.py @@ -34,7 +34,7 @@ def __call__(self, event: ViewModelChangeEvent) -> None: class ViewModelTest(TestCase): def test_create_ok(self): meta = FieldMeta.from_schema("x", Schema(**{"type": "integer", "default": -1})) - vm = ViewModel.create(meta) + vm = ViewModel.from_field_meta(meta) self.assertIsInstance(vm, PrimitiveViewModel) self.assertEqual(-1, vm.value) @@ -48,7 +48,7 @@ def test_create_ok(self): } ), ) - vm = ViewModel.create(meta) + vm = ViewModel.from_field_meta(meta) self.assertIsInstance(vm, ArrayViewModel) self.assertEqual([0.1, 0.5], vm.value) @@ -65,7 +65,7 @@ def test_create_ok(self): } ), ) - vm = ViewModel.create(meta) + vm = ViewModel.from_field_meta(meta) self.assertIsInstance(vm, ObjectViewModel) self.assertEqual({"x": 10, "y": -20}, vm.value) @@ -79,14 +79,14 @@ def test_create_ok(self): } ), ) - vm = ViewModel.create(meta) + vm = ViewModel.from_field_meta(meta) self.assertIsInstance(vm, NullableViewModel) self.assertEqual(None, vm.value) def test_create_failing(self): meta = FieldMeta.from_schema("x", Schema(**{})) with pytest.raises(ValueError, match="missing type in schema for field 'x'"): - ViewModel.create(meta) + ViewModel.from_field_meta(meta) def test_primitive_ok(self): meta = FieldMeta.from_schema("x", Schema(**{"type": "integer"})) @@ -247,17 +247,6 @@ def test_object_failing(self): } ), ) - with pytest.raises( - ValueError, - match=r"invalid view model passed for property 'b' of field 'x'", - ): - ObjectViewModel( - meta, - properties={ - "a": PrimitiveViewModel(meta.properties["a"]), - "b": PrimitiveViewModel(meta.properties["a"]), - }, - ) def test_nullable_ok(self): meta = FieldMeta.from_schema(