Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 44 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
29a891b
Added webkitdirectory to FileSelector
JosuaCarl Oct 31, 2024
fd04ccd
Added webkitdirectory to factory + testing output in file uploader
JosuaCarl Oct 31, 2024
b84fd82
Added webkitdirectory to viselelments.json
JosuaCarl Oct 31, 2024
3c83faa
Build files
JosuaCarl Oct 31, 2024
0f30134
Extended InputHTML Attributes to include webkitdirectory
JosuaCarl Oct 31, 2024
38102e8
Extended InputHTML Attributes to include webkitdirectory
JosuaCarl Oct 31, 2024
fbbfc94
Package locks
JosuaCarl Oct 31, 2024
9be0b4b
Added webkitRealtivePath to saved blob properties
JosuaCarl Oct 31, 2024
bbde8c2
Handle webkitdirectory correctly as a string
JosuaCarl Oct 31, 2024
44a0c5f
Added test for webkitdirectory support
JosuaCarl Oct 31, 2024
9246198
Merge branch 'Avaiga:develop' into develop
JosuaCarl Nov 4, 2024
402fb73
Merge branch 'develop' into develop
JosuaCarl Nov 6, 2024
45c7b21
Merge branch 'develop' into develop
JosuaCarl Nov 7, 2024
372821a
Merge branch 'develop' into develop
FredLL-Avaiga Nov 7, 2024
c9daab7
Addressed linting: E501 + W292
JosuaCarl Nov 7, 2024
645cdb5
Addressed failing jest test.
JosuaCarl Nov 7, 2024
9b94d39
Merge branch 'develop' of github.com:JosuaCarl/taipy into develop
JosuaCarl Nov 7, 2024
198ef94
Changed naming scheme; Used directory to cirumvent naming limitations…
JosuaCarl Nov 8, 2024
2d23976
Added comments to __upload_files() in gui.py; Simplified checks in __…
JosuaCarl Nov 8, 2024
2272089
Edited styling of CONTRIBUTING.md for better readability and clarity …
JosuaCarl Nov 8, 2024
eb8ddf8
Normed path
JosuaCarl Nov 8, 2024
b661e75
Changed naming Scheme; Added integration of other directory attribute…
JosuaCarl Nov 8, 2024
5984576
Added mandatory case information for python to CONTRIBUTING.md
JosuaCarl Nov 8, 2024
f05ffc5
Fixed testing caseing.
JosuaCarl Nov 8, 2024
4dcad4b
Merge branch 'develop' into develop
JosuaCarl Nov 8, 2024
ba7c1fc
Merge branch 'develop' into develop
FredLL-Avaiga Nov 8, 2024
0e8e4f2
Merge branch 'develop' into develop
FredLL-Avaiga Nov 12, 2024
f9d64ee
Addressed stylistic errors: "W291 [*] Trailing whitespace"; "E711 Com…
JosuaCarl Nov 12, 2024
b491410
Removed unnecessary ignore
JosuaCarl Nov 12, 2024
91bc664
Deleted unnecessary package-lock
JosuaCarl Nov 12, 2024
b6b9d7b
Restored original package.json in frontend/taipy
JosuaCarl Nov 12, 2024
b8decae
Added test for folder upload.
JosuaCarl Nov 12, 2024
70160db
Merge branch 'develop' into develop
FredLL-Avaiga Nov 12, 2024
32c6d71
Check whether the final directory is a child of the root upload direc…
JosuaCarl Nov 12, 2024
db366f5
Fixed check for upload staying inplace; removed print from test
JosuaCarl Nov 12, 2024
fdaf910
Changed path testing to string testing.
JosuaC Nov 12, 2024
673ed23
Addressed linter errors.
JosuaC Nov 12, 2024
3f81477
Addressed C901 `__upload_files` is too complex (19 > 18)
JosuaCarl Nov 13, 2024
bcc3acc
Changed unnecessary files to match latest origin commit 37b924f05aba1…
JosuaCarl Nov 13, 2024
72033d3
Merge branch 'develop' into develop
FredLL-Avaiga Nov 13, 2024
6f62f33
Merge branch 'develop' into develop
FredLL-Avaiga Nov 13, 2024
5836583
Changed naming of select_folder to selection_type
JosuaCarl Nov 13, 2024
c50c736
Merge branch 'develop' of github.com:JosuaCarl/taipy into develop
JosuaCarl Nov 13, 2024
aa3967f
Fixed spelling error; Removed default setting of property; Accounted …
JosuaCarl Nov 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"

FredLL-Avaiga marked this conversation as resolved.
Show resolved Hide resolved
# 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:
Fixed Show fixed Hide fixed
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:
Fixed Show fixed Hide fixed
grouped_file.write(part_file.read())
# remove file_path after it is merged
part_file_path.unlink()
Fixed Show fixed Hide fixed
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) )
Loading