Commit 45a5ddf9 authored by Duggan, John's avatar Duggan, John
Browse files

Merge remote-tracking branch 'origin/main' into 70-add-component-for-selecting-datafiles-from-oncat

parents 01cbf7b0 44f574e2
Loading
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -2,6 +2,14 @@

* Added data_source and projection parameters to NeutronDataSelector to allow populating data files from ONCat (thanks to Andrew Ayres and John Duggan).

### nova-trame, 0.25.5

* NeutronDataSelector will no longer show duplicates of a file that matches multiple extensions (thanks to John Duggan).

### nova-trame, 0.25.4

* InputField, FileUpload, and RemoteFileInput should support parameter bindings now (thanks to John Duggan).

### nova-trame, 0.25.3

* Clearing NeutronDataSelector file selections will no longer send null/None values to the state (thanks to John Duggan).
+1 −0
Original line number Diff line number Diff line
@@ -100,6 +100,7 @@ class DataSelectorModel:
                        for extension in self.state.extensions:
                            if entry.path.lower().endswith(extension):
                                datafiles.append(entry.path)
                                break
                    else:
                        datafiles.append(entry.path)
        except OSError:
+35 −13
Original line number Diff line number Diff line
@@ -3,21 +3,39 @@
import os
from functools import cmp_to_key
from locale import strcoll
from typing import Any, Union
from typing import Any, List, Union

from pydantic import BaseModel, Field


class RemoteFileInputState(BaseModel):
    """Pydantic model for RemoteFileInput state."""

    allow_files: bool = Field(default=False)
    allow_folders: bool = Field(default=False)
    base_paths: List[str] = Field(default=[])
    extensions: List[str] = Field(default=[])


class RemoteFileInputModel:
    """Manages interactions between RemoteFileInput and the file system."""

    def __init__(self, allow_files: bool, allow_folders: bool, base_paths: list[str], extensions: list[str]) -> None:
    def __init__(self) -> None:
        """Creates a new RemoteFileInputModel."""
        self.allow_files = allow_files
        self.allow_folders = allow_folders
        self.base_paths = base_paths
        self.extensions = extensions
        self.state = RemoteFileInputState()

    def set_binding_parameters(self, **kwargs: Any) -> None:
        if "allow_files" in kwargs:
            self.state.allow_files = kwargs["allow_files"]
        if "allow_folders" in kwargs:
            self.state.allow_folders = kwargs["allow_folders"]
        if "base_paths" in kwargs:
            self.state.base_paths = kwargs["base_paths"]
        if "extensions" in kwargs:
            self.state.extensions = kwargs["extensions"]

    def get_base_paths(self) -> list[dict[str, Any]]:
        return [{"path": base_path, "directory": True} for base_path in self.base_paths]
        return [{"path": base_path, "directory": True} for base_path in self.state.base_paths]

    def scan_current_path(
        self, current_path: str, showing_all_files: bool, filter: str
@@ -72,7 +90,7 @@ class RemoteFileInputModel:
        if not showing_base_paths and file != "..":
            return os.path.join(old_path, file)
        elif not showing_base_paths:
            if old_path in self.base_paths:
            if old_path in self.state.base_paths:
                return ""
            else:
                return os.path.dirname(old_path)
@@ -83,17 +101,21 @@ class RemoteFileInputModel:
        if entry.is_dir():
            return True

        if not self.allow_files:
        if not self.state.allow_files:
            return False

        return showing_all_files or not self.extensions or any(entry.name.endswith(ext) for ext in self.extensions)
        return (
            showing_all_files
            or not self.state.extensions
            or any(entry.name.endswith(ext) for ext in self.state.extensions)
        )

    def valid_selection(self, selection: str) -> bool:
        if self.valid_subpath(selection):
            if os.path.isdir(selection) and self.allow_folders:
            if os.path.isdir(selection) and self.state.allow_folders:
                return True

            if os.path.isfile(selection) and self.allow_files:
            if os.path.isfile(selection) and self.state.allow_files:
                return True

        return False
@@ -102,7 +124,7 @@ class RemoteFileInputModel:
        if subpath == "":
            return False

        for base_path in self.base_paths:
        for base_path in self.state.base_paths:
            if subpath.startswith(base_path):
                return True

+27 −13
Original line number Diff line number Diff line
"""View implementation for FileUpload."""

from typing import Any, List, Optional
from typing import Any, List, Tuple, Union

from trame.app import get_server
from trame.widgets import vuetify3 as vuetify
from trame_server.core import State

from nova.trame._internal.utils import get_state_param

from .remote_file_input import RemoteFileInput

@@ -12,25 +16,28 @@ class FileUpload(vuetify.VBtn):

    def __init__(
        self,
        v_model: str,
        base_paths: Optional[List[str]] = None,
        v_model: Union[str, Tuple],
        base_paths: Union[List[str], Tuple, None] = None,
        extensions: Union[List[str], Tuple, None] = None,
        label: str = "",
        return_contents: bool = True,
        return_contents: Union[bool, Tuple] = True,
        **kwargs: Any,
    ) -> None:
        """Constructor for FileUpload.

        Parameters
        ----------
        v_model : str
        v_model : Union[str, Tuple]
            The state variable to set when the user uploads their file. The state variable will contain a latin1-decoded
            version of the file contents. If your file is binary or requires a different string encoding, then you can
            call `encode('latin1')` on the file contents to get the underlying bytes.
        base_paths: list[str], optional
        base_paths: Union[List[str], Tuple], optional
            Passed to :ref:`RemoteFileInput <api_remotefileinput>`.
        extensions: Union[List[str], Tuple], optional
            Restricts the files shown to the user to files that end with one of the strings in the list.
        label : str, optional
            The text to display on the upload button.
        return_contents : bool, optional
        return_contents : Union[bool, Tuple], optional
            If true, the file contents will be stored in v_model. If false, a file path will be stored in v_model.
            Defaults to true.
        **kwargs
@@ -41,20 +48,26 @@ class FileUpload(vuetify.VBtn):
        -------
        None
        """
        self._server = get_server(None, client_type="vue3")

        self._v_model = v_model
        if base_paths:
            self._base_paths = base_paths
        else:
            self._base_paths = ["/"]
        self._base_paths = base_paths if base_paths else ["/"]
        self._extensions = extensions if extensions else []
        self._return_contents = return_contents
        self._ref_name = f"nova__fileupload_{self._next_id}"

        super().__init__(label, **kwargs)
        self.create_ui()

    @property
    def state(self) -> State:
        return self._server.state

    def create_ui(self) -> None:
        self.local_file_input = vuetify.VFileInput(
            v_model=(self._v_model, None),
            v_model=self._v_model,
            __properties=["accept"],
            accept=",".join(self._extensions) if isinstance(self._extensions, list) else self._extensions,
            classes="d-none",
            ref=self._ref_name,
            # Serialize the content in a way that will work with nova-mvvm and then push it to the server.
@@ -67,6 +80,7 @@ class FileUpload(vuetify.VBtn):
        self.remote_file_input = RemoteFileInput(
            v_model=self._v_model,
            base_paths=self._base_paths,
            extensions=self._extensions,
            input_props={"classes": "d-none"},
            return_contents=self._return_contents,
        )
@@ -79,7 +93,7 @@ class FileUpload(vuetify.VBtn):

        @self.server.controller.trigger(f"decode_blob_{self._id}")
        def _decode_blob(contents: bytes) -> None:
            self.remote_file_input.decode_file(contents, self._return_contents)
            self.remote_file_input.decode_file(contents, get_state_param(self.state, self._return_contents))

    def select_file(self, value: str) -> None:
        """Programmatically set the RemoteFileInput path.
+48 −19
Original line number Diff line number Diff line
@@ -5,7 +5,7 @@ import os
import re
from enum import Enum
from inspect import isclass
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Tuple, Union

from trame.app import get_server
from trame.widgets import client
@@ -15,6 +15,7 @@ from trame_server.controller import Controller
from trame_server.state import State

from nova.mvvm.pydantic_utils import get_field_info
from nova.trame._internal.utils import set_state_param

logger = logging.getLogger(__name__)

@@ -22,9 +23,14 @@ logger = logging.getLogger(__name__)
class InputField:
    """Factory class for generating Vuetify input components."""

    next_id = 0

    @staticmethod
    def create_boilerplate_properties(
        v_model: Optional[Union[tuple[str, Any], str]], field_type: str, debounce: int, throttle: int
        v_model: Union[str, Tuple, None],
        field_type: str,
        debounce: Union[int, Tuple],
        throttle: Union[int, Tuple],
    ) -> dict:
        if debounce == -1:
            debounce = int(os.environ.get("NOVA_TRAME_DEFAULT_DEBOUNCE", 0))
@@ -78,26 +84,43 @@ class InputField:
                ):
                    args |= {"items": str([option.value for option in field_info.annotation])}

            if debounce > 0 and throttle > 0:
            if debounce and throttle:
                raise ValueError("debounce and throttle cannot be used together")

            if debounce > 0:
            server = get_server(None, client_type="vue3")
            if debounce:
                if isinstance(debounce, tuple):
                    debounce_field = debounce[0]
                    set_state_param(server.state, debounce)
                else:
                    debounce_field = f"nova__debounce_{InputField.next_id}"
                    InputField.next_id += 1
                    set_state_param(server.state, debounce_field, debounce)

                args |= {
                    "update_modelValue": (
                        "window.delay_manager.debounce("
                        f"  '{v_model}',"
                        f"  '{field}',"
                        f"  () => flushState('{object_name_in_state}'),"
                        f"  {debounce}"
                        f"  {debounce_field}"
                        ")"
                    )
                }
            elif throttle > 0:
            elif throttle:
                if isinstance(throttle, tuple):
                    throttle_field = throttle[0]
                    set_state_param(server.state, throttle)
                else:
                    throttle_field = f"nova__throttle_{InputField.next_id}"
                    InputField.next_id += 1
                    set_state_param(server.state, throttle_field, throttle)

                args |= {
                    "update_modelValue": (
                        "window.delay_manager.throttle("
                        f"  '{v_model}',"
                        f"  '{field}',"
                        f"  () => flushState('{object_name_in_state}'),"
                        f"  {throttle}"
                        f"  {throttle_field}"
                        ")"
                    )
                }
@@ -107,10 +130,10 @@ class InputField:

    def __new__(
        cls,
        v_model: Optional[Union[tuple[str, Any], str]] = None,
        v_model: Union[str, Tuple, None] = None,
        required: bool = False,
        debounce: int = -1,
        throttle: int = -1,
        debounce: Union[int, Tuple] = -1,
        throttle: Union[int, Tuple] = -1,
        type: str = "text",
        **kwargs: Any,
    ) -> AbstractElement:
@@ -118,18 +141,19 @@ class InputField:

        Parameters
        ----------
        v_model : tuple[str, Any] or str, optional
        v_model : Union[str, Tuple], optional
            The v-model for this component. If this references a Pydantic configuration variable, then this component
            will attempt to load a label, hint, and validation rules from the configuration for you automatically.
        required : bool
        required : bool, optional
            If true, the input will be visually marked as required and a required rule will be added to the end of the
            rules list.
        debounce : int
            rules list. This parameter will be removed in the future. Please use Pydantic to enforce validation of
            required fields.
        debounce : Union[int, Tuple], optional
            Number of milliseconds to wait after the last user interaction with this field before attempting to update
            the Trame state. If set to 0, then no debouncing will occur. If set to -1, then the environment variable
            `NOVA_TRAME_DEFAULT_DEBOUNCE` will be used to set this (defaults to 0). See the `Lodash Docs
            <https://lodash.com/docs/4.17.15#debounce>`__ for details.
        throttle : int
        throttle : Union[int, Tuple], optional
            Number of milliseconds to wait between updates to the Trame state when the user is interacting with this
            field. If set to 0, then no throttling will occur. If set to -1, then the environment variable
            `NOVA_TRAME_DEFAULT_THROTTLE` will be used to set this (defaults to 0). See the `Lodash Docs
@@ -165,7 +189,9 @@ class InputField:
            - switch
            - textarea

            Any other value will produce a text field with your type used as an HTML input type attribute.
            Any other value will produce a text field with your type used as an HTML input type attribute. Note that
            parameter does not support binding since swapping field types dynamically produces a confusing user
            experience.
        **kwargs
            All other arguments will be passed to the underlying
            `Trame Vuetify component <https://trame.readthedocs.io/en/latest/trame.widgets.vuetify3.html>`_.
@@ -184,7 +210,10 @@ class InputField:
        """
        server = get_server(None, client_type="vue3")

        kwargs = {**cls.create_boilerplate_properties(v_model, type, debounce, throttle), **kwargs}
        kwargs = {
            **cls.create_boilerplate_properties(v_model, type, debounce, throttle),
            **kwargs,
        }

        if "__events" not in kwargs or kwargs["__events"] is None:
            kwargs["__events"] = []
Loading