Skip to content

Commit

Permalink
New feature implementation/#2189 Add support for uploading nested fol…
Browse files Browse the repository at this point in the history
…ders (#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 <[email protected]>
Co-authored-by: JosuaCarl <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent ad79eef commit 0b44b5d
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 54 deletions.
33 changes: 21 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/taipy-gui/packaging/taipy-gui.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface FileSelectorProps extends TaipyActiveProps {
defaultLabel?: string;
label?: string;
multiple?: boolean;
selectionType?: string;
extensions?: string;
dropMessage?: string;
notify?: boolean;
Expand Down
59 changes: 59 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/FileSelector.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}));
Expand Down Expand Up @@ -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 changes: 8 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/FileSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -194,6 +201,7 @@ const FileSelector = (props: FileSelectorProps) => {
type="file"
accept={extensions}
multiple={multiple}
{...directoryProps}
onChange={handleChange}
disabled={!active || upload}
/>
Expand Down
3 changes: 3 additions & 0 deletions frontend/taipy-gui/src/workers/fileupload.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const uploadFile = (
part: number,
total: number,
fileName: string,
filePath: string,
multiple: boolean,
id: string,
progressCb: (uploaded: number) => void
Expand All @@ -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);
};

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions taipy/gui/_renderers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
100 changes: 58 additions & 42 deletions taipy/gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,8 @@ def __upload_files(self):
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")
Expand All @@ -998,58 +1000,72 @@ def __upload_files(self):
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
Expand Down
6 changes: 6 additions & 0 deletions taipy/gui/viselements.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions tests/gui/control/test_file_selector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 20 additions & 0 deletions tests/gui/server/http/test_file_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import inspect
import io
import os
import pathlib
import tempfile

Expand Down Expand Up @@ -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) )

0 comments on commit 0b44b5d

Please sign in to comment.