Unverified Commit c8595e1b authored by Duggan, John's avatar Duggan, John Committed by GitHub
Browse files

Merge pull request #135 from nova-model/121-fileupload---add-binding-support-for-parameters

Add binding support for RemoteFileInput and FileUpload
parents 2d4dd18b 87d59ad9
Loading
Loading
Loading
Loading
+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=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.
+114 −37
Original line number Diff line number Diff line
@@ -2,14 +2,17 @@

from functools import partial
from tempfile import NamedTemporaryFile
from typing import Any, Optional, Union, cast
from typing import Any, List, Optional, Tuple, Union, cast

from trame.app import get_server
from trame.widgets import client, html
from trame.widgets import vuetify3 as vuetify
from trame_client.widgets.core import AbstractElement
from trame_server.core import State

from nova.mvvm._internal.utils import rgetdictvalue
from nova.mvvm.trame_binding import TrameBinding
from nova.trame._internal.utils import get_state_name, set_state_param
from nova.trame.model.remote_file_input import RemoteFileInputModel
from nova.trame.view_model.remote_file_input import RemoteFileInputViewModel

@@ -24,57 +27,47 @@ class RemoteFileInput:

    def __init__(
        self,
        v_model: Optional[Union[tuple[str, Any], str]] = None,
        allow_files: bool = True,
        allow_folders: bool = False,
        allow_nonexistent_path: bool = False,
        base_paths: Optional[list[str]] = None,
        v_model: Union[str, Tuple],
        allow_files: Union[bool, Tuple] = True,
        allow_folders: Union[bool, Tuple] = False,
        base_paths: Union[List[str], Tuple, None] = None,
        dialog_props: Optional[dict[str, Any]] = None,
        extensions: Optional[list[str]] = None,
        extensions: Union[List[str], Tuple, None] = None,
        input_props: Optional[dict[str, Any]] = None,
        return_contents: bool = False,
        return_contents: Union[bool, Tuple] = False,
    ) -> None:
        """Constructor for RemoteFileInput.

        Parameters
        ----------
        v_model : tuple[str, Any] or str, optional
        v_model : Union[str, Tuple]
            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.
        allow_files : bool
        allow_files : Union[bool, Tuple], optional
            If true, the user can save a file selection.
        allow_folders : bool
        allow_folders : Union[bool, Tuple], optional
            If true, the user can save a folder selection.
        allow_nonexistent_path : bool
            If false, the user will be warned when they've selected a non-existent path on the filesystem.
        base_paths : list[str], optional
        base_paths : Union[List[str], Tuple], optional
            Only files under these paths will be shown.
        dialog_props : dict[str, typing.Any], optional
        dialog_props : Dict[str, typing.Any], optional
            Props to be passed to VDialog.
        extensions : list[str], optional
        extensions : Union[List[str], Tuple], optional
            Only files with these extensions will be shown by default. The user can still choose to view all files.
        input_props : dict[str, typing.Any], optional
        input_props : Dict[str, typing.Any], optional
            Props to be passed to InputField.
        return_contents : bool
        return_contents : Union[bool, Tuple], optional
            If true, then the v_model will contain the contents of the file. If false, then the v_model will contain the
            path of the file.

        Raises
        ------
        ValueError
            If v_model is None.
            path of the file. Defaults to false.

        Returns
        -------
        None
        """
        if v_model is None:
            raise ValueError("RemoteFileInput must have a v_model attribute.")
        self.server = get_server(None, client_type="vue3")

        self.v_model = v_model
        self.allow_files = allow_files
        self.allow_folders = allow_folders
        self.allow_nonexistent_path = allow_nonexistent_path
        self.base_paths = base_paths if base_paths else ["/"]
        self.dialog_props = dict(dialog_props) if dialog_props else {}
        self.extensions = extensions if extensions else []
@@ -88,10 +81,16 @@ class RemoteFileInput:
        if "width" not in self.dialog_props:
            self.dialog_props["width"] = 600

        self.create_model()
        self.create_viewmodel()
        self._create_model()
        self._create_viewmodel()
        self._setup_bindings()

        self.create_ui()

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

    def create_ui(self) -> None:
        with cast(
            AbstractElement,
@@ -175,12 +174,11 @@ class RemoteFileInput:
                                    click=partial(self.vm.close_dialog, cancel=True),
                                )

    def create_model(self) -> None:
        self.model = RemoteFileInputModel(self.allow_files, self.allow_folders, self.base_paths, self.extensions)
    def _create_model(self) -> None:
        self.model = RemoteFileInputModel()

    def create_viewmodel(self) -> None:
        server = get_server(None, client_type="vue3")
        binding = TrameBinding(server.state)
    def _create_viewmodel(self) -> None:
        binding = TrameBinding(self.state)

        if isinstance(self.v_model, tuple):
            model_name = self.v_model[0]
@@ -197,12 +195,91 @@ class RemoteFileInput:
        self.vm.file_list_bind.connect(self.vm.get_file_list_state_name())
        self.vm.filter_bind.connect(self.vm.get_filter_state_name())
        self.vm.on_close_bind.connect(client.JSEval(exec=f"{self.vm.get_dialog_state_name()} = false;").exec)
        if self.return_contents:
        self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name())
        self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name())

    # This method sets up Trame state change listeners for each binding parameter that can be changed directly by this
    # component. This allows us to communicate the changes to the developer's bindings without requiring our own. We
    # don't want bindings in the internal implementation as our callbacks could compete with the developer's.
    def _setup_bindings(self) -> None:
        # If the bindings were given initial values, write these to the state.
        self._last_allow_files = set_state_param(self.state, self.allow_files)
        self._last_allow_folders = set_state_param(self.state, self.allow_folders)
        self._last_base_paths = set_state_param(self.state, self.base_paths)
        self._last_extensions = set_state_param(self.state, self.extensions)
        self._last_return_contents = set_state_param(self.state, self.return_contents)

        # Now we need to propagate the state to this component's view model.
        self.vm.set_binding_parameters(
            allow_files=self.allow_files,
            allow_folders=self.allow_folders,
            base_paths=self.base_paths,
            extensions=self.extensions,
        )
        self._setup_update_binding(self._last_return_contents)

        # Now we set up the change listeners for all bound parameters. These are responsible for updating the component
        # when other portions of the application manipulate these parameters.
        if isinstance(self.allow_files, tuple):

            @self.state.change(get_state_name(self.allow_files[0]))
            def on_allow_files_change(**kwargs: Any) -> None:
                if isinstance(self.allow_files, bool):
                    return
                allow_files = rgetdictvalue(kwargs, self.allow_files[0])
                if allow_files != self._last_allow_files:
                    self.vm.set_binding_parameters(
                        allow_files=set_state_param(self.state, self.allow_files, allow_files)
                    )

        if isinstance(self.allow_folders, tuple):

            @self.state.change(get_state_name(self.allow_folders[0]))
            def on_allow_folders_change(**kwargs: Any) -> None:
                if isinstance(self.allow_folders, bool):
                    return
                allow_folders = rgetdictvalue(kwargs, self.allow_folders[0])
                if allow_folders != self._last_allow_folders:
                    self.vm.set_binding_parameters(
                        allow_folders=set_state_param(self.state, self.allow_folders, allow_folders)
                    )

        if isinstance(self.base_paths, tuple):

            @self.state.change(get_state_name(self.base_paths[0]))
            def on_base_paths_change(**kwargs: Any) -> None:
                if isinstance(self.base_paths, bool):
                    return
                base_paths = rgetdictvalue(kwargs, self.base_paths[0])
                if base_paths != self._last_base_paths:
                    self.vm.set_binding_parameters(base_paths=set_state_param(self.state, self.base_paths, base_paths))

        if isinstance(self.extensions, tuple):

            @self.state.change(get_state_name(self.extensions[0]))
            def on_extensions_change(**kwargs: Any) -> None:
                if isinstance(self.extensions, bool):
                    return
                extensions = rgetdictvalue(kwargs, self.extensions[0])
                if extensions != self._last_extensions:
                    self.vm.set_binding_parameters(extensions=set_state_param(self.state, self.extensions, extensions))

        if isinstance(self.return_contents, tuple):

            @self.state.change(get_state_name(self.return_contents[0]))
            def on_return_contents_change(**kwargs: Any) -> None:
                if isinstance(self.return_contents, bool):
                    return
                return_contents = rgetdictvalue(kwargs, self.return_contents[0])
                if return_contents != self._last_return_contents:
                    self._setup_update_binding(return_contents)

    def _setup_update_binding(self, read_file: bool) -> None:
        self.vm.reset_update_binding()
        if read_file:
            self.vm.on_update_bind.connect(self.read_file)
        else:
            self.vm.on_update_bind.connect(self.set_v_model)
        self.vm.showing_all_bind.connect(self.vm.get_showing_all_state_name())
        self.vm.valid_selection_bind.connect(self.vm.get_valid_selection_state_name())

    def read_file(self, file_path: str) -> None:
        with open(file_path, mode="rb") as file:
+14 −7
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ class RemoteFileInputViewModel:
    def __init__(self, model: RemoteFileInputModel, binding: BindingInterface) -> None:
        """Creates a new RemoteFileInputViewModel."""
        self.model = model
        self.binding = binding

        # Needed to keep state variables separated if this class is instantiated multiple times.
        self.id = RemoteFileInputViewModel.counter
@@ -23,13 +24,16 @@ class RemoteFileInputViewModel:
        self.showing_base_paths = True
        self.previous_value = ""
        self.value = ""
        self.dialog_bind = binding.new_bind()
        self.file_list_bind = binding.new_bind()
        self.filter_bind = binding.new_bind()
        self.showing_all_bind = binding.new_bind()
        self.valid_selection_bind = binding.new_bind()
        self.on_close_bind = binding.new_bind()
        self.on_update_bind = binding.new_bind()
        self.dialog_bind = self.binding.new_bind()
        self.file_list_bind = self.binding.new_bind()
        self.filter_bind = self.binding.new_bind()
        self.showing_all_bind = self.binding.new_bind()
        self.valid_selection_bind = self.binding.new_bind()
        self.on_close_bind = self.binding.new_bind()
        self.on_update_bind = self.binding.new_bind()

    def reset_update_binding(self) -> None:
        self.on_update_bind = self.binding.new_bind()

    def open_dialog(self) -> None:
        self.previous_value = self.value
@@ -93,3 +97,6 @@ class RemoteFileInputViewModel:

        self.valid_selection_bind.update_in_view(self.model.valid_selection(new_path))
        self.populate_file_list()

    def set_binding_parameters(self, **kwargs: Any) -> None:
        self.model.set_binding_parameters(**kwargs)
+4 −0
Original line number Diff line number Diff line
"""Model for MVVM demo of FileUpload."""

from typing import List

from pydantic import BaseModel, Field


class FileUploadState(BaseModel):
    """Model for MVVM demo of FileUpload."""

    extensions: List[str] = Field(default=[".cif", ".nxs"])
    file: str = Field(default="")
    label: str = Field(default="Upload File")
Loading