Fix directory-only glob for FileExplorer (#6689)

* Fix glob

* add changeset

* Fix test

* lint

* sort

* Remove empty

* Select directory + test

* Workign fix

* WOrkign fix

* Directory click 😅

* add changeset

* Fix test

* Add dirs

* Select directory + test

* WOrkign fix

* Directory click 😅

* WIP

* add code

* Lint

* Use same color as file icon

* Fix test

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
Freddy Boulton 2023-12-12 19:39:11 -05:00 committed by GitHub
parent 6a9151d5c9
commit c9673cacd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 372 additions and 27 deletions

View File

@ -0,0 +1,6 @@
---
"@gradio/fileexplorer": patch
"gradio": patch
---
fix:Fix directory-only glob for FileExplorer

View File

@ -1 +1 @@
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer_component_events"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["# Downloading files from the demo repo\n", "import os\n", "os.mkdir('dir1')\n", "!wget -q -O dir1/bar.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/bar.txt\n", "!wget -q -O dir1/foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/foo.txt\n", "os.mkdir('dir2')\n", "!wget -q -O dir2/baz.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/baz.png\n", "!wget -q -O dir2/foo.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/foo.png"]}, {"cell_type": "code", "execution_count": null, "id": "44380577570523278879349135829904343037", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "base_root = Path(__file__).parent.resolve()\n", "\n", "with gr.Blocks() as demo:\n", " dd = gr.Dropdown(\n", " label=\"Select File Explorer Root\",\n", " value=str(base_root / \"dir1\"),\n", " choices=[str(base_root / \"dir1\"), str(base_root / \"dir2\")],\n", " )\n", " fe = gr.FileExplorer(root=str(base_root / \"dir1\"), interactive=True)\n", " dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])\n", "\n", " with gr.Row():\n", " a = gr.Textbox(elem_id=\"input-box\")\n", " a.change(lambda x: x, inputs=[a])\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer_component_events"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["# Downloading files from the demo repo\n", "import os\n", "os.mkdir('dir1')\n", "!wget -q -O dir1/bar.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/bar.txt\n", "!wget -q -O dir1/foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/foo.txt\n", "os.mkdir('dir2')\n", "!wget -q -O dir2/baz.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/baz.png\n", "!wget -q -O dir2/foo.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/foo.png\n", "os.mkdir('dir3')\n", "!wget -q -O dir3/dir3_foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir3_foo.txt\n", "!wget -q -O dir3/dir4 https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir4"]}, {"cell_type": "code", "execution_count": null, "id": "44380577570523278879349135829904343037", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "base_root = Path(__file__).parent.resolve()\n", "\n", "with gr.Blocks() as demo:\n", " with gr.Row():\n", " dd = gr.Dropdown(label=\"Select File Explorer Root\",\n", " value=str(base_root / \"dir1\"),\n", " choices=[str(base_root / \"dir1\"), str(base_root / \"dir2\"),\n", " str(base_root / \"dir3\")])\n", " with gr.Group():\n", " dir_only_glob = gr.Checkbox(label=\"Show only directories\", value=False)\n", " ignore_dir_in_glob = gr.Checkbox(label=\"Ignore directories in glob\", value=False)\n", "\n", " fe = gr.FileExplorer(root=str(base_root / \"dir1\"),\n", " glob=\"**/*\", interactive=True)\n", " textbox = gr.Textbox(label=\"Selected Directory\")\n", " run = gr.Button(\"Run\")\n", " \n", " dir_only_glob.select(lambda s: gr.FileExplorer(glob=\"**/\" if s else \"**/*.*\",\n", " file_count=\"multiple\",\n", " root=str(base_root / \"dir3\")) ,\n", " inputs=[dir_only_glob], outputs=[fe])\n", " ignore_dir_in_glob.select(lambda s: gr.FileExplorer(glob=\"**/*\",\n", " ignore_glob=\"**/\",\n", " root=str(base_root / \"dir3\")),\n", " inputs=[ignore_dir_in_glob], outputs=[fe]) \n", "\n", " dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])\n", " run.click(lambda s: \",\".join(s) if isinstance(s, list) else s, inputs=[fe], outputs=[textbox])\n", "\n", " with gr.Row():\n", " a = gr.Textbox(elem_id=\"input-box\")\n", " a.change(lambda x: x, inputs=[a])\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}

View File

@ -4,13 +4,31 @@ from pathlib import Path
base_root = Path(__file__).parent.resolve()
with gr.Blocks() as demo:
dd = gr.Dropdown(
label="Select File Explorer Root",
value=str(base_root / "dir1"),
choices=[str(base_root / "dir1"), str(base_root / "dir2")],
)
fe = gr.FileExplorer(root=str(base_root / "dir1"), interactive=True)
with gr.Row():
dd = gr.Dropdown(label="Select File Explorer Root",
value=str(base_root / "dir1"),
choices=[str(base_root / "dir1"), str(base_root / "dir2"),
str(base_root / "dir3")])
with gr.Group():
dir_only_glob = gr.Checkbox(label="Show only directories", value=False)
ignore_dir_in_glob = gr.Checkbox(label="Ignore directories in glob", value=False)
fe = gr.FileExplorer(root=str(base_root / "dir1"),
glob="**/*", interactive=True)
textbox = gr.Textbox(label="Selected Directory")
run = gr.Button("Run")
dir_only_glob.select(lambda s: gr.FileExplorer(glob="**/" if s else "**/*.*",
file_count="multiple",
root=str(base_root / "dir3")) ,
inputs=[dir_only_glob], outputs=[fe])
ignore_dir_in_glob.select(lambda s: gr.FileExplorer(glob="**/*",
ignore_glob="**/",
root=str(base_root / "dir3")),
inputs=[ignore_dir_in_glob], outputs=[fe])
dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])
run.click(lambda s: ",".join(s) if isinstance(s, list) else s, inputs=[fe], outputs=[textbox])
with gr.Row():
a = gr.Textbox(elem_id="input-box")

View File

@ -116,8 +116,11 @@ class FileExplorer(Component):
return None
else:
return self._safe_join(payload.root[0])
return [self._safe_join(file) for file in (payload.root)]
files = []
for file in payload.root:
file_ = self._safe_join(file)
files.append(file_)
return files
def _strip_root(self, path):
if path.startswith(self.root):
@ -129,10 +132,11 @@ class FileExplorer(Component):
return None
files = [value] if isinstance(value, str) else value
root = []
for file in files:
root.append(self._strip_root(file).split(os.path.sep))
return FileExplorerData(
root=[self._strip_root(file).split(os.path.sep) for file in files]
)
return FileExplorerData(root=root)
@server
def ls(self, value=None) -> list[dict[str, str]] | None:
@ -190,17 +194,26 @@ class FileExplorer(Component):
_tree.append({"path": parts[i], "type": type, "children": []})
_tree = _tree[-1]["children"]
files = []
files: list[Path] = []
for result in expand_braces(self.glob):
files += list(Path(self.root).resolve().glob(result))
files = [f for f in files if f != Path(self.root).resolve()]
ignore_files = []
if self.ignore_glob:
for result in expand_braces(self.ignore_glob):
ignore_files += list(Path(self.root).resolve().glob(result))
files = list(set(files) - set(ignore_files))
tree = make_tree([str(f.relative_to(self.root)) for f in files])
files_with_sep = []
for f in files:
file = str(f.relative_to(self.root))
if f.is_dir():
file += os.path.sep
files_with_sep.append(file)
tree = make_tree(files_with_sep)
return tree
def _safe_join(self, folders):

View File

@ -37,3 +37,120 @@ test("File Explorer is interactive and re-runs the server_fn when root is update
page.locator("span").filter({ hasText: "foo.png" }).getByRole("checkbox")
).toBeChecked();
});
test("File Explorer correctly displays both directories and files. Directories included in value.", async ({
page
}) => {
await page.getByLabel("Select File Explorer Root").click();
await page.getByLabel(new RegExp("/dir3$"), { exact: true }).first().click();
await page
.locator("span")
.filter({ hasText: "dir4" })
.getByLabel("expand directory")
.click();
await page
.locator("li")
.filter({ hasText: "dir4 dir5 dir7 . dir_4_foo.txt" })
.getByRole("checkbox")
.nth(3)
.check();
await page
.locator("span")
.filter({ hasText: "dir_4_foo.txt" })
.getByRole("checkbox")
.check();
await page
.locator("span")
.filter({ hasText: "dir3_foo.txt" })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Run" }).click();
const directory_paths_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const files = value.split(",");
expect(files.some((f) => f.endsWith("dir4"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir_4_foo.txt"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir3_foo.txt"))).toBeTruthy();
};
await expect(directory_paths_displayed).toPass();
});
test("File Explorer selects all children when top level directory is selected.", async ({
page
}) => {
await page.getByLabel("Select File Explorer Root").click();
await page.getByLabel(new RegExp("/dir3$"), { exact: true }).first().click();
await page
.locator("span")
.filter({ hasText: "dir4" })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Run" }).click();
const directory_paths_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const files_and_dirs = value.split(",");
expect(files_and_dirs.length).toBe(6);
};
await expect(directory_paths_displayed).toPass();
});
test("File Explorer correctly displays only directories and properly adds it to the value", async ({
page
}) => {
const check = page.getByRole("checkbox", {
name: "Show only directories",
exact: true
});
await check.click();
await page
.locator("span")
.filter({ hasText: "dir4" })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Run" }).click();
const directory_paths_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const dirs = value.split(",");
expect(dirs.some((f) => f.endsWith("dir4"))).toBeTruthy();
expect(dirs.some((f) => f.endsWith("dir5"))).toBeTruthy();
expect(dirs.some((f) => f.endsWith("dir7"))).toBeTruthy();
};
await expect(directory_paths_displayed).toPass();
});
test("File Explorer correctly excludes directories when ignore_glob is '**/'.", async ({
page
}) => {
const check = page.getByRole("checkbox", {
name: "Ignore directories in glob",
exact: true
});
await check.click();
await page
.locator("span")
.filter({ hasText: "dir4" })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Run" }).click();
const only_files_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const files = value.split(",");
expect(files.length).toBe(3);
expect(files.some((f) => f.endsWith("dir_4_foo.txt"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir5_foo.txt"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir7_foo.txt"))).toBeTruthy();
};
await expect(only_files_displayed).toPass();
});

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg7"
sodipodi:docname="light-folder-new.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="7.375"
inkscape:cx="15.932203"
inkscape:cy="16"
inkscape:window-width="1312"
inkscape:window-height="529"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="svg7" />
<defs
id="defs6">
<clipPath
id="clipPath1">
<path
d="m69.63 12.145h-.052c-22.727-.292-46.47 4.077-46.709 4.122-2.424.451-4.946 2.974-5.397 5.397-.044.237-4.414 23.983-4.122 46.71-.292 22.777 4.078 46.523 4.122 46.761.451 2.423 2.974 4.945 5.398 5.398.237.044 23.982 4.413 46.709 4.121 22.779.292 46.524-4.077 46.761-4.121 2.423-.452 4.946-2.976 5.398-5.399.044-.236 4.413-23.981 4.121-46.709.292-22.777-4.077-46.523-4.121-46.761-.453-2.423-2.976-4.946-5.398-5.397-.238-.045-23.984-4.414-46.71-4.122"
id="path1" />
</clipPath>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="352.98"
x2="-601.15"
y1="663.95"
x1="-591.02"
id="2">
<stop
stop-color="#a0a0a0"
id="stop1" />
<stop
offset="1"
stop-color="#aaa"
id="stop2" />
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="354.29"
x2="-704.05"
y1="647.77"
x1="-701.19"
id="1">
<stop
stop-color="#acabab"
id="stop3" />
<stop
offset="1"
stop-color="#d4d4d4"
id="stop4" />
</linearGradient>
<linearGradient
id="0"
x1="59.12"
y1="-19.888"
x2="59.15"
y2="-37.783"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.17478 0 0 4.16765-1069.7 447.73)">
<stop
stop-color="#a0a0a0"
id="stop5" />
<stop
offset="1"
stop-color="#bdbdbd"
id="stop6" />
</linearGradient>
</defs>
<g
transform="matrix(.07089 0 0 .07017 23.295-40.67)"
fill="#60aae5"
id="g7"
style="fill:#888888;fill-opacity:1">
<path
transform="matrix(.7872 0 0 .79524 415.34 430.11)"
d="m-884.1 294.78c-4.626 0-8.349 3.718-8.349 8.335v161.41l468.19 1v-121.2c0-4.618-3.724-8.335-8.35-8.335h-272.65c-8.51.751-9.607-.377-13.812-5.981-5.964-7.968-14.969-21.443-20.84-29.21-4.712-6.805-5.477-6.02-13.292-6.02z"
fill="url(#0)"
color="#000"
id="path6"
style="fill:#888888;fill-opacity:1" />
<rect
transform="matrix(.7872 0 0 .79524 415.34 430.11)"
y="356.85"
x="-890.28"
height="295.13"
width="463.85"
fill="url(#1)"
stroke="url(#1)"
stroke-width="2.378"
rx="9.63"
id="rect6"
style="fill:#888888;fill-opacity:1" />
<rect
width="463.85"
height="295.13"
x="-890.28"
y="356.85"
transform="matrix(.7872 0 0 .79524 415.34 430.11)"
fill="none"
stroke="url(#2)"
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="5.376"
rx="9.63"
id="rect7"
style="fill:#888888;fill-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -2,7 +2,6 @@
import { createEventDispatcher } from "svelte";
export let value: boolean;
export let disabled: boolean;
const dispatch = createEventDispatcher<{ change: boolean }>();
</script>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { Node } from "./utils";
import { dequal } from "dequal";
import FileTree from "./FileTree.svelte";
import { make_fs_store } from "./utils";

View File

@ -5,6 +5,7 @@
import Arrow from "./ArrowIcon.svelte";
import Checkbox from "./Checkbox.svelte";
import FileIcon from "../icons/light-file.svg";
import FolderIcon from "../icons/light-folder.svg";
export let interactive: boolean;
export let tree: Node[] = [];
@ -51,12 +52,16 @@
(tree[i].children_visible = !tree[i].children_visible)}
><Arrow /></span
>
{:else if path === ""}
<span class="file-icon">
<img src={FolderIcon} alt="folder icon" />
</span>
{:else}
<span class="file-icon">
<img src={FileIcon} alt="file icon" />
</span>
{/if}
{path}
{path ? path : "."}
</span>
{#if children && children_visible}
<svelte:self

View File

@ -1,5 +1,6 @@
import { writable, type Readable } from "svelte/store";
import { dequal } from "dequal";
export interface Node {
type: "file" | "folder";
path: string;
@ -75,16 +76,18 @@ export const make_fs_store = (): FSStore => {
ensure_visible(_node);
const nodes = check_node_and_children(_node.children, true, [_node]);
check_parent(_node);
nodes.forEach((node) => {
const path = get_full_path(node);
if (seen_nodes.has(path.join("/"))) {
return;
}
const normalized_path = path.join("/");
// let normalized_path = path.join("/");
// normalized_path = normalized_path.endsWith("/") ? normalized_path.slice(0, -1) : normalized_path;
if (node.type === "file") {
if (seen_nodes.has(normalized_path)) {
return;
}
new_checked_paths.push(path);
seen_nodes.add(normalized_path);
}
seen_nodes.add(path.join("/"));
});
}
@ -119,20 +122,19 @@ export const make_fs_store = (): FSStore => {
for (let i = 0; i < nodes.length; i++) {
const _path = get_full_path(nodes[i]);
if (!checked) {
if (!nodes[i].checked) {
new_checked_paths.delete(_path.join("/"));
} else if (checked) {
} else if (nodes[i].checked) {
if (file_count === "single") {
new_checked_paths = new Map();
}
if (nodes[i].type === "file") {
new_checked_paths.set(_path.join("/"), _path);
}
new_checked_paths.set(_path.join("/"), _path);
}
}
check_parent(_node);
set(root.children!);
old_checked_paths = Array.from(new_checked_paths).map((v) => v[1]);
return old_checked_paths;
@ -261,7 +263,6 @@ function check_parent(node: Node | null | undefined): void {
nodes_checked.push(_node.checked);
_node = _node.previous;
}
if (nodes_checked.every((v) => v === true)) {
node.parent!.checked = true;
check_parent(node?.parent);

View File

@ -2734,6 +2734,67 @@ class TestFileExplorer:
preprocessed_data = file_explorer.preprocess(input_data)
assert preprocessed_data == []
def test_file_explorer_dir_only_glob(self, tmpdir):
tmpdir.mkdir("foo")
tmpdir.mkdir("bar")
tmpdir.mkdir("baz")
(Path(tmpdir) / "baz" / "qux").mkdir()
(Path(tmpdir) / "foo" / "abc").mkdir()
(Path(tmpdir) / "foo" / "abc" / "def").mkdir()
(Path(tmpdir) / "foo" / "abc" / "def" / "file.txt").touch()
file_explorer = gr.FileExplorer(glob="**/", root=Path(tmpdir))
tree = file_explorer.ls()
def sort_answer(answer):
answer = sorted(answer, key=lambda x: x["path"])
for item in answer:
if item["children"]:
item["children"] = sort_answer(item["children"])
return answer
answer = [
{
"path": "bar",
"type": "folder",
"children": [{"path": "", "type": "file", "children": None}],
},
{
"path": "baz",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "qux",
"type": "folder",
"children": [{"path": "", "type": "file", "children": None}],
},
],
},
{
"path": "foo",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "abc",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "def",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None}
],
},
],
},
],
},
]
assert sort_answer(tree) == sort_answer(answer)
def test_component_class_ids():
button_id = gr.Button().component_class_id