Преглед на файлове

New feature implementation/#2189 Add support for uploading nested folders (#2206)

* Added webkitdirectory to FileSelector

* Added webkitdirectory to factory + testing output in file uploader

* Added webkitdirectory to viselelments.json

* Build files

* Extended InputHTML Attributes to include webkitdirectory

* Extended InputHTML Attributes to include webkitdirectory

* Package locks

* Added webkitRealtivePath to saved blob properties

* Handle webkitdirectory correctly as a string

* Added test for webkitdirectory support

* Addressed linting: E501 + W292

* Addressed failing jest test.

* Changed naming scheme; Used directory to cirumvent naming limitations of React's attribute naming; Adjusted tests for addition of other attributes.

* Added comments to __upload_files() in gui.py; Simplified checks in __upload_files (addresses linter C901) .

* Edited styling of CONTRIBUTING.md for better readability and clarity in execution.

* Normed path

* Changed naming Scheme; Added integration of other directory attributes and changed attribute to boolean.

* Added mandatory case information for python to CONTRIBUTING.md

* Fixed testing caseing.

* Addressed stylistic errors: "W291 [*] Trailing whitespace"; "E711 Comparison to `None` should be `cond is None`"; "W293 [*] Blank line contains whitespace"

* Removed unnecessary ignore

* Deleted unnecessary package-lock

* Restored original package.json in frontend/taipy

* Added test for folder upload.

* Check whether the final directory is a child of the root upload directory.

* Fixed check for upload staying inplace; removed print from test

* Changed path testing to string testing.

* Addressed linter errors.

* Addressed C901 `__upload_files` is too complex (19 > 18)

* Changed unnecessary files to match latest origin commit 37b924f05aba1c814c75098c8ec1750a74e3770

* Changed naming of select_folder to selection_type

* Fixed spelling error; Removed default setting of property; Accounted for different input casing;

---------

Co-authored-by: Fred Lefévère-Laoide <90181748+FredLL-Avaiga@users.noreply.github.com>
Co-authored-by: JosuaCarl <josua.carl@student.uni-tuebingen.de>
Josua Carl преди 6 месеца
родител
ревизия
0b44b5d396

+ 21 - 12
CONTRIBUTING.md

@@ -129,7 +129,7 @@ issue or PR if you're still interested in working on it.
 ### Python
 
 Taipy's repositories follow the [PEP 8](https://www.python.org/dev/peps/pep-0008/) and
-[PEP 484](https://www.python.org/dev/peps/pep-0484/) coding convention.
+[PEP 484](https://www.python.org/dev/peps/pep-0484/) coding convention. Gui variables need to be `snake_case` to be correctly converted into the `camelCase`, used for typescript variables.
 
 ### TypeScript
 
@@ -255,17 +255,26 @@ npm run build:dev
 This will preserve the debugging symbols, and you will be able to navigate in the TypeScript code
 from your debugger.
 
-!!!note "Web application location"
-    When you are developing front-end code for the Taipy GUI package, it may be cumbersome to have
-    to install the package over and over when you know that all that has changed is the JavaScript
-    bundle that makes the Taipy web app.
-
-    By default, the Taipy GUI application searches for the front-end code in the
-    `[taipy-gui-package-dir]/taipy/gui/webapp` directory.
-    You can, however, set the environment variable `TAIPY_GUI_WEBAPP_PATH` to the location of your
-    choice, and Taipy GUI will look for the web app in that directory.
-    If you set this variable to the location where you build the web app repeatedly, you will no
-    longer have to reinstall Taipy GUI before you try your code again.
+#### 📝A note on "Web application location"
+When you are developing front-end code for the Taipy GUI package, it may be cumbersome to have
+to install the package over and over when you know that all that has changed is the JavaScript
+bundle that makes the Taipy web app.
+
+By default, the Taipy GUI application searches for the front-end code in the
+`[taipy-gui-package-dir]/taipy/gui/webapp` directory.
+You can, however, set the environment variable `TAIPY_GUI_WEBAPP_PATH` to the location of your
+choice, and Taipy GUI will look for the web app in that directory.
+If you set this variable to the location where you build the web app repeatedly, you will no
+longer have to reinstall Taipy GUI before you try your code again.
+In python, you can handle this with:
+```python
+import os
+os.environ["TAIPY_GUI_WEBAPP_PATH"] = os.path.normpath( "/path/to/your/taipy/taipy/gui/webapp" )
+```
+or in bash with:
+```bash
+export TAIPY_GUI_WEBAPP_PATH="/path/to/your/taipy/taipy/gui/webapp"
+```
 
 ### Running the tests
 

+ 1 - 0
frontend/taipy-gui/packaging/taipy-gui.d.ts

@@ -166,6 +166,7 @@ export interface FileSelectorProps extends TaipyActiveProps {
     defaultLabel?: string;
     label?: string;
     multiple?: boolean;
+    selectionType?: string;
     extensions?: string;
     dropMessage?: string;
     notify?: boolean;

+ 59 - 0
frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx

@@ -21,6 +21,7 @@ import { TaipyContext } from "../../context/taipyContext";
 import { TaipyState, INITIAL_STATE } from "../../context/taipyReducers";
 import { uploadFile } from "../../workers/fileupload";
 
+
 jest.mock("../../workers/fileupload", () => ({
     uploadFile: jest.fn().mockResolvedValue("mocked response"), // returns a Promise that resolves to 'mocked response'
 }));
@@ -283,4 +284,62 @@ describe("FileSelector Component", () => {
         // Check if the dispatch function has not been called
         expect(mockDispatch).not.toHaveBeenCalled();
     });
+
+    it("checks the appearance of folder upload options in input", async () => {
+        const mockDispatch = jest.fn();
+
+        // Render HTML Document
+        const { getByLabelText } = render(
+            <TaipyContext.Provider value={{ state: INITIAL_STATE, dispatch: mockDispatch }}>
+                <FileSelector label="FileSelector" selectionType="dir" />
+            </TaipyContext.Provider>
+        );
+
+        // Simulate folder upload
+        const folder = new Blob([""], { type: "" });
+        const selectorElt = getByLabelText("FileSelector");
+        fireEvent.change(selectorElt, { target: { files: [folder] } });
+
+        // Wait for the upload to complete
+        await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
+        
+        // Check for input element
+        const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
+        expect(inputElt).toBeInTheDocument();
+
+        // Check attributes of <input>
+        expect(inputElt?.getAttribute("directory")).toBe("");
+        expect(inputElt?.getAttribute("webkitdirectory")).toBe("");
+        expect(inputElt?.getAttribute("mozdirectory")).toBe("");
+        expect(inputElt?.getAttribute("nwdirectory")).toBe("");
+    });
+
+    it("checks the absence of folder upload options, when selection type is set accordingly", async () => {
+        const mockDispatch = jest.fn();
+
+        // Render HTML Document
+        const { getByLabelText } = render(
+            <TaipyContext.Provider value={{ state: INITIAL_STATE, dispatch: mockDispatch }}>
+                <FileSelector label="FileSelector" selectionType="" />
+            </TaipyContext.Provider>
+        );
+
+        // Simulate folder upload
+        const file = new Blob(["(o.O)"], { type: "" });
+        const selectorElt = getByLabelText("FileSelector");
+        fireEvent.change(selectorElt, { target: { files: [file] } });
+
+        // Wait for the upload to complete
+        await waitFor(() => expect(mockDispatch).toHaveBeenCalled());
+        
+        // Check for input element
+        const inputElt = selectorElt.parentElement?.parentElement?.querySelector("input");
+        expect(inputElt).toBeInTheDocument();
+
+        // Check attributes of <input>
+        expect(inputElt?.getAttributeNames()).not.toContain("directory");
+        expect(inputElt?.getAttributeNames()).not.toContain("webkitdirectory");
+        expect(inputElt?.getAttributeNames()).not.toContain("mozdirectory");
+        expect(inputElt?.getAttributeNames()).not.toContain("nwdirectory");
+    });
 });

+ 8 - 0
frontend/taipy-gui/src/components/Taipy/FileSelector.tsx

@@ -35,11 +35,14 @@ import { uploadFile } from "../../workers/fileupload";
 import { SxProps } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
 
+
+
 interface FileSelectorProps extends TaipyActiveProps {
     onAction?: string;
     defaultLabel?: string;
     label?: string;
     multiple?: boolean;
+    selectionType?: string;
     extensions?: string;
     dropMessage?: string;
     notify?: boolean;
@@ -65,12 +68,16 @@ const FileSelector = (props: FileSelectorProps) => {
         defaultLabel = "",
         updateVarName = "",
         multiple = false,
+        selectionType = "file",
         extensions = ".csv,.xlsx",
         dropMessage = "Drop here to Upload",
         label,
         notify = true,
         withBorder = true,
     } = props;
+    const directoryProps = ["d", "dir", "directory", "folder"].includes(selectionType?.toLowerCase()) ? 
+                           {webkitdirectory: "", directory: "", mozdirectory: "", nwdirectory: ""} : 
+                           undefined;
     const [dropLabel, setDropLabel] = useState("");
     const [dropSx, setDropSx] = useState<SxProps | undefined>(defaultSx);
     const [upload, setUpload] = useState(false);
@@ -194,6 +201,7 @@ const FileSelector = (props: FileSelectorProps) => {
                 type="file"
                 accept={extensions}
                 multiple={multiple}
+                {...directoryProps}
                 onChange={handleChange}
                 disabled={!active || upload}
             />

+ 3 - 0
frontend/taipy-gui/src/workers/fileupload.worker.ts

@@ -23,6 +23,7 @@ const uploadFile = (
     part: number,
     total: number,
     fileName: string,
+    filePath: string,
     multiple: boolean,
     id: string,
     progressCb: (uploaded: number) => void
@@ -40,6 +41,7 @@ const uploadFile = (
     onAction && fdata.append("on_action", onAction);
     uploadData && fdata.append("upload_data", uploadData);
     fdata.append("multiple", multiple ? "True" : "False");
+    fdata.append("path", filePath)
     xhr.send(fdata);
 };
 
@@ -90,6 +92,7 @@ const process = (
                     Math.floor(start / BYTES_PER_CHUNK),
                     tot,
                     blob.name,
+                    blob.webkitRelativePath,
                     i == 0 ? false : files.length > 0,
                     id,
                     progressCallback

+ 1 - 0
taipy/gui/_renderers/factory.py

@@ -262,6 +262,7 @@ class _Factory:
                 ("on_action", PropertyType.function),
                 ("active", PropertyType.dynamic_boolean, True),
                 ("multiple", PropertyType.boolean, False),
+                ("selection_type", PropertyType.string),
                 ("extensions",),
                 ("drop_message",),
                 ("hover_text", PropertyType.dynamic_string),

+ 58 - 42
taipy/gui/gui.py

@@ -989,6 +989,8 @@ class Gui:
         context = request.form.get("context", None)
         upload_data = request.form.get("upload_data", None)
         multiple = "multiple" in request.form and request.form["multiple"] == "True"
+
+        # File parsing and checks
         file = request.files.get("blob", None)
         if not file:
             _warn("upload files: No file part")
@@ -998,58 +1000,72 @@ class Gui:
         if file.filename == "":
             _warn("upload files: No selected file")
             return ("upload files: No selected file", 400)
+
+        # Path parsing and checks
+        path = request.form.get("path", "")
         suffix = ""
         complete = True
         part = 0
+
         if "total" in request.form:
             total = int(request.form["total"])
             if total > 1 and "part" in request.form:
                 part = int(request.form["part"])
                 suffix = f".part.{part}"
                 complete = part == total - 1
-        if file:  # and allowed_file(file.filename)
-            upload_path = Path(self._get_config("upload_folder", tempfile.gettempdir())).resolve()
+
+        # Extract upload path (when single file is selected, path="" does not change the path)
+        upload_root = os.path.abspath( self._get_config( "upload_folder", tempfile.gettempdir() ) )
+        upload_path = os.path.abspath( os.path.join( upload_root, os.path.dirname(path) ) )
+        if upload_path.startswith( upload_root ):
+            upload_path = Path( upload_path ).resolve()
+            os.makedirs( upload_path, exist_ok=True )
+            # Save file into upload_path directory
             file_path = _get_non_existent_file_path(upload_path, secure_filename(file.filename))
-            file.save(str(upload_path / (file_path.name + suffix)))
-            if complete:
-                if part > 0:
-                    try:
-                        with open(file_path, "wb") as grouped_file:
-                            for nb in range(part + 1):
-                                part_file_path = upload_path / f"{file_path.name}.part.{nb}"
-                                with open(part_file_path, "rb") as part_file:
-                                    grouped_file.write(part_file.read())
-                                # remove file_path after it is merged
-                                part_file_path.unlink()
-                    except EnvironmentError as ee:  # pragma: no cover
-                        _warn(f"Cannot group file after chunk upload for {file.filename}", ee)
-                        return (f"Cannot group file after chunk upload for {file.filename}", 500)
-                # notify the file is uploaded
-                newvalue = str(file_path)
-                if multiple and var_name:
-                    value = _getscopeattr(self, var_name)
-                    if not isinstance(value, t.List):
-                        value = [] if value is None else [value]
-                    value.append(newvalue)
-                    newvalue = value
-                with self._set_locals_context(context):
-                    if on_upload_action:
-                        data = {}
-                        if upload_data:
-                            try:
-                                data = json.loads(upload_data)
-                            except Exception:
-                                pass
-                        data["path"] = file_path
-                        file_fn = self._get_user_function(on_upload_action)
-                        if not _is_function(file_fn):
-                            file_fn = _getscopeattr(self, on_upload_action)
-                        if _is_function(file_fn):
-                            self._call_function_with_state(
-                                t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}]
-                            )
-                    else:
-                        setattr(self._bindings(), var_name, newvalue)
+            file.save( os.path.join( upload_path, (file_path.name + suffix) ) )
+        else:
+            _warn(f"upload files: Path {path} points outside of upload root.")
+            return("upload files: Path part points outside of upload root.", 400)
+
+        if complete:
+            if part > 0:
+                try:
+                    with open(file_path, "wb") as grouped_file:
+                        for nb in range(part + 1):
+                            part_file_path = upload_path / f"{file_path.name}.part.{nb}"
+                            with open(part_file_path, "rb") as part_file:
+                                grouped_file.write(part_file.read())
+                            # remove file_path after it is merged
+                            part_file_path.unlink()
+                except EnvironmentError as ee:  # pragma: no cover
+                    _warn(f"Cannot group file after chunk upload for {file.filename}", ee)
+                    return (f"Cannot group file after chunk upload for {file.filename}", 500)
+            # notify the file is uploaded
+            newvalue = str(file_path)
+            if multiple and var_name:
+                value = _getscopeattr(self, var_name)
+                if not isinstance(value, t.List):
+                    value = [] if value is None else [value]
+                value.append(newvalue)
+                newvalue = value
+            with self._set_locals_context(context):
+                if on_upload_action:
+                    data = {}
+                    if upload_data:
+                        try:
+                            data = json.loads(upload_data)
+                        except Exception:
+                            pass
+                    data["path"] = file_path
+                    file_fn = self._get_user_function(on_upload_action)
+                    if not _is_function(file_fn):
+                        file_fn = _getscopeattr(self, on_upload_action)
+                    if _is_function(file_fn):
+                        self._call_function_with_state(
+                            t.cast(t.Callable, file_fn), ["file_upload", {"args": [data]}]
+                        )
+                else:
+                    setattr(self._bindings(), var_name, newvalue)
         return ("", 200)
 
     def __send_var_list_update(  # noqa C901

+ 6 - 0
taipy/gui/viselements.json

@@ -1227,6 +1227,12 @@
                         "default_value": "False",
                         "doc": "If set to True, multiple files can be uploaded."
                     },
+                    {
+                        "name": "selection_type",
+                        "type": "str",
+                        "default_value": "\"file\"",
+                        "doc": "Can be set to \"file\" (with \"f\", \"\" aliases) or \"directory\" (with \"d\", \"dir\", \"folder\" aliases) to upload the respective element with preserved inner structure."
+                    },
                     {
                         "name": "extensions",
                         "type": "str",

+ 12 - 0
tests/gui/control/test_file_selector.py

@@ -47,3 +47,15 @@ def test_file_selector_html(gui: Gui, test_client, helpers):
         'onAction="action"',
     ]
     helpers.test_control_html(gui, html_string, expected_list)
+
+
+# Testing folder support
+def test_file_selector_folder_md(gui: Gui, test_client, helpers):
+    gui._bind_var_val("content", None)
+    md_string = '<|{content}|file_selector|selection_type=d|>'
+    expected_list = [
+        "<FileSelector",
+        'updateVarName="tpec_TpExPr_content_TPMDL_0"',
+        'selectionType="d"',
+    ]
+    helpers.test_control_md(gui, md_string, expected_list)

+ 20 - 0
tests/gui/server/http/test_file_upload.py

@@ -11,6 +11,7 @@
 
 import inspect
 import io
+import os
 import pathlib
 import tempfile
 
@@ -124,3 +125,22 @@ def test_file_upload_multiple(gui: Gui, helpers):
     assert created_file.exists()
     value = getattr(gui._bindings()._get_all_scopes()[sid], var_name)
     assert len(value) == 2
+
+
+def test_file_upload_folder(gui: Gui, helpers):
+    gui._set_frame(inspect.currentframe())
+    gui.run(run_server=False, single_client=True)
+    flask_client = gui._server.test_client()
+
+    sid = _DataScopes._GLOBAL_ID
+    files = [(io.BytesIO(b"(^~^)"), "cutey.txt"), (io.BytesIO(b"(^~^)"), "cute_nested.txt")]
+    folders = [ ["folder"], ["folder", "nested"] ]
+    for file, folder in zip(files, folders):
+        path = os.path.join(*folder, file[1])
+        response = flask_client.post(
+            f"/taipy-uploads?client_id={sid}",
+            data={"var_name": "cute_varname", "blob": file, "path": path},
+            content_type="multipart/form-data"
+        )
+        assert response.status_code == 200
+        assert os.path.isfile( os.path.join( gui._get_config("upload_folder", tempfile.gettempdir()), path) )