From 66abf991d8989fae223e41c86c04c2ac5622a99c Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 13:28:11 +0100 Subject: [PATCH] feat: feedback verbetering --- project_docs/API_GOLDEN.md | 5 + .../__pycache__/tasks_runner.cpython-313.pyc | Bin 20776 -> 27252 bytes .../copy_task_service.cpython-313.pyc | Bin 10579 -> 12564 bytes .../duplicate_task_service.cpython-313.pyc | Bin 9506 -> 11575 bytes .../move_task_service.cpython-313.pyc | Bin 10845 -> 13622 bytes .../backend/app/services/copy_task_service.py | 87 +++- .../app/services/duplicate_task_service.py | 92 +++- .../backend/app/services/move_task_service.py | 94 +++- webui/backend/app/tasks_runner.py | 464 ++++++++++++------ .../test_api_copy_golden.cpython-313.pyc | Bin 25803 -> 25860 bytes .../test_api_duplicate_golden.cpython-313.pyc | Bin 21857 -> 21799 bytes .../test_api_move_golden.cpython-313.pyc | Bin 33971 -> 34529 bytes .../tests/golden/test_api_copy_golden.py | 8 +- .../tests/golden/test_api_duplicate_golden.py | 6 +- .../tests/golden/test_api_move_golden.py | 9 +- 15 files changed, 585 insertions(+), 180 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 8842f3f..600c567 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -129,6 +129,11 @@ Response shape: } ``` +Voor task-based file-actions `copy`, `move` en `duplicate` betekenen progressvelden: +- `done_items`: aantal volledig verwerkte bestanden +- `total_items`: exact aantal te verwerken bestanden in de hele task +- `current_item`: taakrelatief bestandspad als beschikbaar, anders bestandsnaam + ### `POST /api/tasks/{task_id}/cancel` Success for cancellable file-action task: ```json diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 4efaa92d35d2d579d7a353cfdd8bcca5fd58fa5b..be7b7247eb17af19415da37f6d65d5e03add233c 100644 GIT binary patch literal 27252 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 C<57bE literal 20776 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 KZzJ|$zWyJq<1Ih{ delta 2981 zcmZ`*Z){sv6~FiWv;F)h@&DLKY^P4$zI1IIwHZrj`iHJ*OV_pYt+#F|Gj9CSx^?Vw zpVO`#Wz__O?E@-1D-`i%LIu__Y27L|*cYg%1_%{4I&IZsi17g-7#nFDRlorL#@tIVeZQB*nP&;+ zL#&IH^d+Dl!36Uz!2*aSnQsZ!!Md4WAiNW`JuI+98)O|kbWk#S?FhUQid?!N9j3Y$ zgOdrNx02ASY}Yp6Eht5}ye!(aZ_BXc#7^pJQJuv3t^^@P8UEB8i4;|)s;C{NEt3o> zN^nt$!N-c%yNM7lL4*{%ps0Nyt6{iD^+!8ewuDS{wr7P9Jfa#j0>234LQE@aMOV?y zTq8=|gWD5uBM|O)GmW{0hIvL*$8EB`ec=iDj@UsD(?v{0DNG(zEoqQgINKh=SHc%y zUPm_{H?DnR&HQWRJ<_g7D`HEkt1K`gWynOj?Yh|Z>R}zuj!rnOEuX-v_~gJagA;vi zv`E``!$PC`2r1*{B4uIWW4Hf**#!uTtd&^wS=Y`)Y{ZozCSjzmZ`P;i6d9A#WQrPb zsJII7J6Acp0{z&U)hst>EWQ#K+<C4^nyW*jnKAfFXWx>}0vmj_%{}*K$h}ePg07+_u3L;s6m#l=$$$}bwXt##F;a*M>UPL&4rBxt z7ntKQ;wwrE)#Y{~`j#42igF<-Y-Bf{^?@4Eu@Ypg1i6qxIO4m5H6oS>{4wHLyT~|q z`?TU>GzxOu&Ga>DKVisZ@ie?J*7;=E_Ef6eoNPAu30s*$3K%MM1Xp?o@x}OZA@Xov z@KmC!bgW#fR?1DYw6LMnsv3EM&=Y3GOuR9m9?W)=qT5t~OLlUxSB3Hbwigh?IQd74~Lrr^WSo3Yyg z;!9umb-w2_-u4+U48G$V{UDu%*TOp_q+2DD8#%M{hF9y+>`%Z)iYK{1F42X%3E5|^ z#>QQDA^BQl^>ho(-Pl_FEoYc^nJG-go=OE(hVxXkRg;R&yz85-hz??h%;2xCP7%X> z|F}VI?^L5!YaFjt>qjhJfZuoJH#79e_X<%&VYqFnVcE*@a_tB&3-nPTG$B4OgeW+E z2!-Kp@t_J<6Y0ScBDRM&8qLzta`TX_n)N0>VYv}*r@oc>iZEP_U#Qsd{~_K9w6I=B zahkkuta>^AV*FzIy`dd%5AAsCq5bc`x083p-tX!;xA!~x*LGa*8a%i1t**i6AHLQ# z2#<9S&}*P~=hC;lB$2=AA_I@of7!{0E%6*ZRhUY<7j0r+huMZXLmXR`Eh2=sQt>b?Md4%YWwjfns(!I4bsg2a8>Ip>Js zWu5eS7+>~kY%PLZaQdVxpK&@peh9WgAe)G96RIH;me6PBy^O(ztU=Giq3qY!E^Owa z9=Z6_Vo>gcOIthdI3E8Z5ZiE$-`F44Uc#l7f$3}<8vQ%h2v;YyAtwaB4i^{Dm90)T zX6ns5U*e4L(&u>jPrp}sm4J8od3qKuE?-Bthzoc&7m!{fmpXH!G<+6~ovLkJz&I}9 zXL6UP-Z);WT8%ddd}+mbx(%``vprg??`#xXL|*>ww#?>))#RV!=V5wf_Y(@Q3Y|}g z^C)cP{zlz2=Jz=~cJleXwt>TK%-d4}aQr%KP!2R2HU3rML9QZ~g|8Mh6z*`wF?G-% zpb8HVi8$%=)R082fQI0dk=%XfMv*3dL5Mq1ELXx`Sc*z5i|-?fU-(7qF#N^%^x@sY zcvOfA3fntbD_hpI**w&!a6wW2xPX1)??ZKR9(U?^OejT)xyTUTB80eaJ6bBWtV`u) zlUEPSG)=2i;%DI-13jr9qQz6>wxpQ-`TI&Js*=|*LyW^Uj=ftz=&3)UoBp*M;1aI*ei?$~f7 diff --git a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc index 7319377316341f97e5637f617cd19d1e362cd19f..3cf834041f46c7575f9e5017846e27aed5ca6745 100644 GIT binary patch delta 5357 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 delta 2719 zcmZuzU2I%O6`r|&yZ7$hyZ?V)J6Ug>I2*^ii5;ggP13kAsk@Ht#5Ws?oU*m!+hh}a zH_Y9HHh|1SRZ0|v=0a>1YNu4ry!-AnRR{~ zaV>vy=FB;B&YW}R%>MYo+o!A#f4vbv(599%Qv1``XR1g zBWDPsHer%YnQRAb%a%LH1?2)oFGIG%X{Op?TNRYHg=`^qgyZHvJ8EkidOka5`-HUr zjBdwmKc|PdGx#wBc?nxb-sl}>z)tcbpeYlVQ+8mJO3*f$bxtazz0gGCigRk#y=Xh^ zO6{!kUIh!7?4?VvN1mi%SdtIY`pQq`G^N|&hH@aSqScCQhxuqnhA|6MyDMZm z46mhn;jn7xp<5DgQjIHid{~9gt0`YXC?w&V>Ry_HU#U|x4P9D_W?)FW2}gXrqXn7u z^CRux(2L*&ni%MX>fDn}$Mtk~cG0P_Mx$9!?-6A8ag_rI@S5)<%~NVkXAyqxuV_i+ zxz9p}o~M0qNPh|bt7k$jABpT+BT6LNGD#!>tNNhX3Xn)?m1vRZdKgBGbLq&@e)2}Y zF}hEF%GrO5Lm|(^*)~#DeA!MU(jD$ix&( z1{Y}%!dA*>4N4VxdIy)0gLVj7!7d!bo7Udl7NZe((Aw)nMGTb~Ic>U9iMUdXj714S zJEk2IYo;CF7&ii2lut+#aY6!U*_G<1of?*MWTw>i26JZF?OiQfrj%=kiyczja z;2|z0iewB*(KFgO&fU3{<>&~_w{#LOw1^tFo*Biw2BXg%|I+an;%}K-RwHERF}fay zf5pBQwMbX~gP<>Ft(izYKibma+wn|x-G}9{Y9MQsFSb;o?_4E@ZmcKaop@^3MM^s7 z=!ahBkq_5SQ9CmmijYk}IJJiwFp?Tk z4J@QIV6i|rt;m6j0WYQ!)D&yx-D_$PFJ_l0%ix`|eB}FRFAfvwOekvyiju7kD-Nv~ z(_4XR9gy3h!($mMVPf-=ZKV>}LpLPU=_KC^ex2@!SQY)2r?aEHxX{xSgfkk=LMqcO ze2z=#+EixyCNt`EUN{t(*^coOp9nx4{$BLMA2Werf%8 z!~4XYT@tjw4K zr{H4+oUmOSHkY~RE~3vAk}Sg4Ps7Wdee?|crgK~FQNBgYsW;h^)m!CTy@U?~`ve!Q zL!#^0{jADqUiY3{t2aHx#aG1hxy`CuyXq7|3{!qr_hZw1%5NQ;hgZ7}(*5v$SCJmT z!teAkahG!Nd?A%w#3o+lm+h-4>;42|^##(hRzACBLXuix=-mn@!$`U^LN1qM9i0CJ z{54;c{zf3X?cci!(xgXA_%dqDZL->>g48|)kCBVcp;}vO1`hS~N&g{mp=Sb?cb^Y> zf!ncbHsI#&E%YjM_4i3P36%TS=utRS?4jDqT(OsGFL1lxfagm2Jzu|f<73G1()YY= z#cI9rnQF~#81QcC`qGPBMq^I%GOIHUCk2ho;&3&*P;;9z==r0$&*df?b*Jq;muKJQ zAcUpK$%4^te4>%@GHkarp0%s7>5UG`e;oY3eU{L=GG9B}#K!x@+q!&zPvJ(<8bKwBz;kxL$3SSb&1=ZeQuF+V)s0-omap4VGD26*64iALJB=5dOO_Av{g~DJkmyb#qXS zu3w>% zbJwp(0W}JvgK7BA;DLP1Gpg0cFJlIsYL)SV^nCozE!5_ih{h54Xz=%?&F*^sgNu#% l%L~q7_9N7w+~*=fOQw{*P4a&Nb0`y8lJSD95iy9P{tsl8S^NM1 diff --git a/webui/backend/app/services/copy_task_service.py b/webui/backend/app/services/copy_task_service.py index fe24e70..b8e0d2c 100644 --- a/webui/backend/app/services/copy_task_service.py +++ b/webui/backend/app/services/copy_task_service.py @@ -45,17 +45,14 @@ class CopyTaskService: ) if item["kind"] == "directory": - self._runner.enqueue_copy_directory( - task_id=task["id"], - source=item["source_absolute"], - destination=item["destination_absolute"], - ) + self._runner.enqueue_copy_directory(task_id=task["id"], item=item) else: self._runner.enqueue_copy_file( task_id=task["id"], source=item["source_absolute"], destination=item["destination_absolute"], total_bytes=item["total_bytes"], + current_item=item["files"][0]["label"], ) return TaskCreateResponse(task_id=task["id"], status=task["status"]) @@ -94,6 +91,7 @@ class CopyTaskService: destination=destination, resolved_destination=resolved_destination_base, destination_base=destination_base, + include_root_prefix=True, ) items.append(item) @@ -118,6 +116,8 @@ class CopyTaskService: "source": item["source_absolute"], "destination": item["destination_absolute"], "kind": item["kind"], + "files": item["files"], + "directories": item["directories"], } for item in items ], @@ -130,6 +130,7 @@ class CopyTaskService: destination: str, resolved_destination: ResolvedPath | None = None, destination_base: str | None = None, + include_root_prefix: bool = False, ) -> dict: resolved_source = self._path_guard.resolve_existing_path(source) _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) @@ -151,9 +152,6 @@ class CopyTaskService: details={"path": source}, ) - if source_is_directory: - self._validate_directory_tree(resolved_source) - resolved_destination = resolved_destination or self._path_guard.resolve_path(destination) destination_absolute = ( resolved_destination.absolute / resolved_source.absolute.name @@ -189,6 +187,22 @@ class CopyTaskService: details={"path": source, "destination": destination_relative}, ) + if source_is_directory: + directories, files = self._build_directory_plan( + resolved_source=resolved_source, + destination_root=destination_absolute, + include_root_prefix=include_root_prefix, + ) + else: + files = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_absolute), + "label": resolved_source.absolute.name, + } + ] + directories = [] + return { "source_relative": resolved_source.relative, "destination_relative": destination_relative, @@ -196,6 +210,8 @@ class CopyTaskService: "destination_absolute": str(destination_absolute), "kind": "directory" if source_is_directory else "file", "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, + "files": files, + "directories": directories, } def _map_directory_validation(self, relative_path: str) -> None: @@ -211,10 +227,25 @@ class CopyTaskService: ) raise - def _validate_directory_tree(self, resolved_source: ResolvedPath) -> None: + def _build_directory_plan( + self, + *, + resolved_source: ResolvedPath, + destination_root: Path, + include_root_prefix: bool, + ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + directories: list[dict[str, str]] = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_root), + } + ] + files: list[dict[str, str]] = [] for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): root_path = Path(root) - for name in [*dirnames, *filenames]: + dirnames.sort(key=str.lower) + filenames.sort(key=str.lower) + for name in dirnames: entry = root_path / name if entry.is_symlink(): raise AppError( @@ -223,6 +254,42 @@ class CopyTaskService: status_code=409, details={"path": resolved_source.relative}, ) + relative = entry.relative_to(resolved_source.absolute) + directories.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + } + ) + for name in filenames: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + relative = entry.relative_to(resolved_source.absolute) + files.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + "label": self._progress_label( + top_level_name=resolved_source.absolute.name, + relative_path=relative, + include_root_prefix=include_root_prefix, + ), + } + ) + return directories, files + + @staticmethod + def _progress_label(*, top_level_name: str, relative_path: Path, include_root_prefix: bool) -> str: + relative_value = relative_path.as_posix() + if not relative_value: + return top_level_name + return f"{top_level_name}/{relative_value}" if include_root_prefix else relative_value @staticmethod def _join_destination_base(destination_base: str, name: str) -> str: diff --git a/webui/backend/app/services/duplicate_task_service.py b/webui/backend/app/services/duplicate_task_service.py index 2442e1e..52dec54 100644 --- a/webui/backend/app/services/duplicate_task_service.py +++ b/webui/backend/app/services/duplicate_task_service.py @@ -31,7 +31,11 @@ class DuplicateTaskService: items: list[dict[str, str]] = [] reserved_destinations: set[str] = set() for input_path in paths: - item = self._build_duplicate_item(input_path, reserved_destinations) + item = self._build_duplicate_item( + input_path, + reserved_destinations, + include_root_prefix=len(paths) > 1, + ) if item is None: continue reserved_destinations.add(item["destination_absolute"]) @@ -60,6 +64,8 @@ class DuplicateTaskService: "source": item["source_absolute"], "destination": item["destination_absolute"], "kind": item["kind"], + "files": item["files"], + "directories": item["directories"], } for item in items ], @@ -77,7 +83,13 @@ class DuplicateTaskService: ) raise - def _build_duplicate_item(self, source: str, reserved_destinations: set[str]) -> dict[str, str] | None: + def _build_duplicate_item( + self, + source: str, + reserved_destinations: set[str], + *, + include_root_prefix: bool, + ) -> dict[str, str] | None: resolved_source = self._path_guard.resolve_existing_path(source) _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) if self._should_skip_name(lexical_source.name): @@ -100,9 +112,6 @@ class DuplicateTaskService: details={"path": source}, ) - if source_is_directory: - self._validate_directory_tree(resolved_source) - destination_absolute = self._next_duplicate_destination(resolved_source.absolute, reserved_destinations) destination_relative = self._path_guard.entry_relative_path( resolved_source.alias, @@ -110,19 +119,68 @@ class DuplicateTaskService: display_style=resolved_source.display_style, ) + if source_is_directory: + directories, files = self._build_directory_plan( + resolved_source=resolved_source, + destination_root=destination_absolute, + include_root_prefix=include_root_prefix, + ) + else: + files = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_absolute), + "label": resolved_source.absolute.name, + } + ] + directories = [] + return { "source_relative": resolved_source.relative, "destination_relative": destination_relative, "source_absolute": str(resolved_source.absolute), "destination_absolute": str(destination_absolute), "kind": "directory" if source_is_directory else "file", + "files": files, + "directories": directories, } - def _validate_directory_tree(self, resolved_source: ResolvedPath) -> None: + def _build_directory_plan( + self, + *, + resolved_source: ResolvedPath, + destination_root: Path, + include_root_prefix: bool, + ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + directories: list[dict[str, str]] = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_root), + } + ] + files: list[dict[str, str]] = [] for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): dirnames[:] = [name for name in dirnames if not self._should_skip_name(name)] + dirnames.sort(key=str.lower) + filenames = sorted(filenames, key=str.lower) root_path = Path(root) - for name in [*dirnames, *filenames]: + for name in dirnames: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + relative = entry.relative_to(resolved_source.absolute) + directories.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + } + ) + for name in filenames: if self._should_skip_name(name): continue entry = root_path / name @@ -133,6 +191,26 @@ class DuplicateTaskService: status_code=409, details={"path": resolved_source.relative}, ) + relative = entry.relative_to(resolved_source.absolute) + files.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + "label": self._progress_label( + top_level_name=resolved_source.absolute.name, + relative_path=relative, + include_root_prefix=include_root_prefix, + ), + } + ) + return directories, files + + @staticmethod + def _progress_label(*, top_level_name: str, relative_path: Path, include_root_prefix: bool) -> str: + relative_value = relative_path.as_posix() + if not relative_value: + return top_level_name + return f"{top_level_name}/{relative_value}" if include_root_prefix else relative_value @classmethod def _next_duplicate_destination(cls, source: Path, reserved_destinations: set[str]) -> Path: diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index abfaa19..09bed2e 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path import uuid @@ -45,11 +46,7 @@ class MoveTaskService: ) if item["kind"] == "directory": - self._runner.enqueue_move_directory( - task_id=task["id"], - source=item["source_absolute"], - destination=item["destination_absolute"], - ) + self._runner.enqueue_move_directory(task_id=task["id"], item=item) else: self._runner.enqueue_move_file( task_id=task["id"], @@ -57,6 +54,7 @@ class MoveTaskService: destination=item["destination_absolute"], total_bytes=item["total_bytes"], same_root=item["same_root"], + current_item=item["files"][0]["label"], ) return TaskCreateResponse(task_id=task["id"], status=task["status"]) @@ -113,6 +111,7 @@ class MoveTaskService: destination=destination, resolved_destination=resolved_destination_base, destination_base=destination_base, + include_root_prefix=True, ) items.append(item) @@ -137,6 +136,8 @@ class MoveTaskService: "source": item["source_absolute"], "destination": item["destination_absolute"], "kind": item["kind"], + "files": item["files"], + "directories": item["directories"], } for item in items ], @@ -149,6 +150,7 @@ class MoveTaskService: destination: str, resolved_destination: ResolvedPath | None = None, destination_base: str | None = None, + include_root_prefix: bool = False, ) -> dict: resolved_source = self._path_guard.resolve_existing_path(source) _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) @@ -224,6 +226,22 @@ class MoveTaskService: details={"path": source, "destination": destination_relative}, ) + if source_is_directory: + directories, files = self._build_directory_plan( + resolved_source=resolved_source, + destination_root=destination_absolute, + include_root_prefix=include_root_prefix, + ) + else: + files = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_absolute), + "label": resolved_source.absolute.name, + } + ] + directories = [] + return { "source_relative": resolved_source.relative, "destination_relative": destination_relative, @@ -232,6 +250,8 @@ class MoveTaskService: "kind": "directory" if source_is_directory else "file", "same_root": same_root, "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, + "files": files, + "directories": directories, } def _map_directory_validation(self, relative_path: str) -> None: @@ -251,6 +271,70 @@ class MoveTaskService: def _join_destination_base(destination_base: str, name: str) -> str: return f"{destination_base.rstrip('/')}/{name}" if destination_base.rstrip("/") else f"/{name}" + def _build_directory_plan( + self, + *, + resolved_source: ResolvedPath, + destination_root: Path, + include_root_prefix: bool, + ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + directories: list[dict[str, str]] = [ + { + "source": str(resolved_source.absolute), + "destination": str(destination_root), + } + ] + files: list[dict[str, str]] = [] + for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): + root_path = Path(root) + dirnames.sort(key=str.lower) + filenames.sort(key=str.lower) + for name in dirnames: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + relative = entry.relative_to(resolved_source.absolute) + directories.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + } + ) + for name in filenames: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + relative = entry.relative_to(resolved_source.absolute) + files.append( + { + "source": str(entry), + "destination": str(destination_root / relative), + "label": self._progress_label( + top_level_name=resolved_source.absolute.name, + relative_path=relative, + include_root_prefix=include_root_prefix, + ), + } + ) + return directories, files + + @staticmethod + def _progress_label(*, top_level_name: str, relative_path: Path, include_root_prefix: bool) -> str: + relative_value = relative_path.as_posix() + if not relative_value: + return top_level_name + return f"{top_level_name}/{relative_value}" if include_root_prefix else relative_value + @staticmethod def _is_nested_destination(source: Path, destination: Path) -> bool: try: diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index f07d1f4..e9ffe1d 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -16,18 +16,18 @@ class TaskRunner: self._filesystem = filesystem self._history_repository = history_repository - def enqueue_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: + def enqueue_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int, current_item: str) -> None: thread = threading.Thread( target=self._run_copy_file, - args=(task_id, source, destination, total_bytes), + args=(task_id, source, destination, total_bytes, current_item), daemon=True, ) thread.start() - def enqueue_copy_directory(self, task_id: str, source: str, destination: str) -> None: + def enqueue_copy_directory(self, task_id: str, item: dict[str, object]) -> None: thread = threading.Thread( target=self._run_copy_directory, - args=(task_id, source, destination), + args=(task_id, item), daemon=True, ) thread.start() @@ -47,18 +47,19 @@ class TaskRunner: destination: str, total_bytes: int, same_root: bool, + current_item: str, ) -> None: thread = threading.Thread( target=self._run_move_file, - args=(task_id, source, destination, total_bytes, same_root), + args=(task_id, source, destination, total_bytes, same_root, current_item), daemon=True, ) thread.start() - def enqueue_move_directory(self, task_id: str, source: str, destination: str) -> None: + def enqueue_move_directory(self, task_id: str, item: dict[str, object]) -> None: thread = threading.Thread( target=self._run_move_directory, - args=(task_id, source, destination), + args=(task_id, item), daemon=True, ) thread.start() @@ -94,14 +95,16 @@ class TaskRunner: ) thread.start() - def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: + def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int, current_item: str) -> None: if not self._repository.mark_running( task_id=task_id, done_bytes=0, total_bytes=total_bytes, - current_item=source, + done_items=0, + total_items=1, + current_item=current_item, ): - self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes) + self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes, done_items=0, total_items=1) return progress = {"done": 0} @@ -112,7 +115,9 @@ class TaskRunner: task_id=task_id, done_bytes=done_bytes, total_bytes=total_bytes, - current_item=source, + done_items=0, + total_items=1, + current_item=current_item, ) try: @@ -121,32 +126,6 @@ class TaskRunner: task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, - ) - except OSError as exc: - self._repository.mark_failed( - task_id=task_id, - error_code="io_error", - error_message=str(exc), - failed_item=source, - done_bytes=progress["done"], - total_bytes=total_bytes, - ) - self._update_history_failed(task_id, str(exc)) - - def _run_copy_directory(self, task_id: str, source: str, destination: str) -> None: - if not self._repository.mark_running( - task_id=task_id, - done_items=0, - total_items=1, - current_item=source, - ): - self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) - return - - try: - self._filesystem.copy_directory(source=source, destination=destination) - self._complete_or_cancel_item_task( - task_id=task_id, done_items=1, total_items=1, ) @@ -156,16 +135,58 @@ class TaskRunner: error_code="io_error", error_message=str(exc), failed_item=source, - done_bytes=None, - total_bytes=None, + done_bytes=progress["done"], + total_bytes=total_bytes, done_items=0, total_items=1, ) self._update_history_failed(task_id, str(exc)) + def _run_copy_directory(self, task_id: str, item: dict[str, object]) -> None: + files = self._file_entries(item) + directories = self._directory_entries(item) + total_items = len(files) + if not self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=files[0]["label"] if files else None, + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return + + try: + completed_items = self._copy_directory_files( + directories, + files, + task_id=task_id, + completed_items=0, + total_items=total_items, + ) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + self._complete_or_cancel_item_task( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=str(item["source"]), + done_bytes=None, + total_bytes=None, + done_items=self._completed_files(task_id), + total_items=total_items, + ) + self._update_history_failed(task_id, str(exc)) + def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: - total_items = len(items) - current_item = items[0]["source"] if items else None + total_items = self._total_file_count(items) + current_item = self._first_file_label(items) if not self._repository.mark_running( task_id=task_id, done_items=0, @@ -180,21 +201,12 @@ class TaskRunner: if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return - source = item["source"] - destination = item["destination"] try: if item["kind"] == "directory": - self._filesystem.copy_directory(source=source, destination=destination) + completed_items = self._copy_directory_item(task_id, item, completed_items, total_items) else: - self._filesystem.copy_file(source=source, destination=destination) - completed_items = index + 1 - next_item = items[index + 1]["source"] if index + 1 < total_items else source - self._repository.update_progress( - task_id=task_id, - done_items=completed_items, - total_items=total_items, - current_item=next_item, - ) + file_entry = self._file_entries(item)[0] + completed_items = self._copy_single_planned_file(task_id, file_entry, completed_items, total_items) if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return @@ -203,7 +215,7 @@ class TaskRunner: task_id=task_id, error_code="io_error", error_message=str(exc), - failed_item=source, + failed_item=str(item["source"]), done_bytes=None, total_bytes=None, done_items=completed_items, @@ -225,14 +237,17 @@ class TaskRunner: destination: str, total_bytes: int, same_root: bool, + current_item: str, ) -> None: if not self._repository.mark_running( task_id=task_id, done_bytes=0, total_bytes=total_bytes, - current_item=source, + done_items=0, + total_items=1, + current_item=current_item, ): - self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes) + self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes, done_items=0, total_items=1) return progress = {"done": 0} @@ -244,6 +259,8 @@ class TaskRunner: task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, + done_items=1, + total_items=1, ) return @@ -253,7 +270,9 @@ class TaskRunner: task_id=task_id, done_bytes=done_bytes, total_bytes=total_bytes, - current_item=source, + done_items=0, + total_items=1, + current_item=current_item, ) self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) @@ -262,32 +281,6 @@ class TaskRunner: task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, - ) - except OSError as exc: - self._repository.mark_failed( - task_id=task_id, - error_code="io_error", - error_message=str(exc), - failed_item=source, - done_bytes=progress["done"], - total_bytes=total_bytes, - ) - self._update_history_failed(task_id, str(exc)) - - def _run_move_directory(self, task_id: str, source: str, destination: str) -> None: - if not self._repository.mark_running( - task_id=task_id, - done_items=0, - total_items=1, - current_item=source, - ): - self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) - return - - try: - self._filesystem.move_directory(source=source, destination=destination) - self._complete_or_cancel_item_task( - task_id=task_id, done_items=1, total_items=1, ) @@ -297,14 +290,56 @@ class TaskRunner: error_code="io_error", error_message=str(exc), failed_item=source, + done_bytes=progress["done"], + total_bytes=total_bytes, done_items=0, total_items=1, ) self._update_history_failed(task_id, str(exc)) + def _run_move_directory(self, task_id: str, item: dict[str, object]) -> None: + files = self._file_entries(item) + directories = self._directory_entries(item) + total_items = len(files) + if not self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=files[0]["label"] if files else None, + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return + + try: + completed_items = self._move_directory_files( + directories, + files, + task_id=task_id, + completed_items=0, + total_items=total_items, + ) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + self._complete_or_cancel_item_task( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=str(item["source"]), + done_items=self._completed_files(task_id), + total_items=total_items, + ) + self._update_history_failed(task_id, str(exc)) + def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: - total_items = len(items) - current_item = items[0]["source"] if items else None + total_items = self._total_file_count(items) + current_item = self._first_file_label(items) if not self._repository.mark_running( task_id=task_id, done_items=0, @@ -319,21 +354,12 @@ class TaskRunner: if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return - source = item["source"] - destination = item["destination"] try: if item["kind"] == "directory": - self._filesystem.move_directory(source=source, destination=destination) + completed_items = self._move_directory_item(task_id, item, completed_items, total_items) else: - self._filesystem.move_file(source=source, destination=destination) - completed_items = index + 1 - next_item = items[index + 1]["source"] if index + 1 < total_items else source - self._repository.update_progress( - task_id=task_id, - done_items=completed_items, - total_items=total_items, - current_item=next_item, - ) + file_entry = self._file_entries(item)[0] + completed_items = self._move_single_planned_file(task_id, file_entry, completed_items, total_items) if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return @@ -342,7 +368,7 @@ class TaskRunner: task_id=task_id, error_code="io_error", error_message=str(exc), - failed_item=source, + failed_item=str(item["source"]), done_bytes=None, total_bytes=None, done_items=completed_items, @@ -358,8 +384,8 @@ class TaskRunner: ) def _run_duplicate_batch(self, task_id: str, items: list[dict[str, str]]) -> None: - total_items = len(items) - current_item = items[0]["source"] if items else None + total_items = self._total_file_count(items) + current_item = self._first_file_label(items) if not self._repository.mark_running( task_id=task_id, done_items=0, @@ -374,31 +400,25 @@ class TaskRunner: if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return - source = item["source"] - destination = item["destination"] try: if item["kind"] == "directory": - self._duplicate_directory(source=Path(source), destination=Path(destination)) + completed_items = self._copy_directory_item(task_id, item, completed_items, total_items, cleanup_on_failure=True) else: - self._filesystem.copy_file(source=source, destination=destination) - completed_items = index + 1 - next_item = items[index + 1]["source"] if index + 1 < total_items else source - self._repository.update_progress( - task_id=task_id, - done_items=completed_items, - total_items=total_items, - current_item=next_item, - ) + file_entry = self._file_entries(item)[0] + completed_items = self._copy_single_planned_file(task_id, file_entry, completed_items, total_items) if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return except OSError as exc: - self._cleanup_partial_duplicate(Path(destination)) + if item["kind"] == "directory": + self._cleanup_partial_duplicate(Path(str(item["destination"]))) + else: + self._cleanup_partial_duplicate(Path(self._file_entries(item)[0]["destination"])) self._repository.mark_failed( task_id=task_id, error_code="io_error", error_message=str(exc), - failed_item=source, + failed_item=str(item["source"]), done_bytes=None, total_bytes=None, done_items=completed_items, @@ -449,40 +469,6 @@ class TaskRunner: ) self._update_history_failed(task_id, str(exc)) - def _duplicate_directory(self, source: Path, destination: Path) -> None: - destination.mkdir() - copied_directories: list[tuple[Path, Path]] = [(source, destination)] - try: - for root, dirnames, filenames in os.walk(source, topdown=True, followlinks=False): - root_path = Path(root) - target_root = destination / root_path.relative_to(source) - dirnames[:] = [name for name in dirnames if not name.startswith("._")] - - for name in dirnames: - source_dir = root_path / name - if source_dir.is_symlink(): - raise OSError("Source directory must not contain symlinks") - target_dir = target_root / name - target_dir.mkdir() - copied_directories.append((source_dir, target_dir)) - - for name in filenames: - if name.startswith("._"): - continue - source_file = root_path / name - if source_file.is_symlink(): - raise OSError("Source directory must not contain symlinks") - self._filesystem.copy_file( - source=str(source_file), - destination=str(target_root / name), - ) - - for source_dir, target_dir in reversed(copied_directories): - shutil.copystat(source_dir, target_dir, follow_symlinks=False) - except Exception: - self._cleanup_partial_duplicate(destination) - raise - def _cleanup_partial_duplicate(self, path: Path) -> None: if not path.exists(): return @@ -491,6 +477,180 @@ class TaskRunner: return path.unlink() + @staticmethod + def _file_entries(item: dict[str, object]) -> list[dict[str, str]]: + return list(item.get("files", [])) # type: ignore[arg-type] + + @staticmethod + def _directory_entries(item: dict[str, object]) -> list[dict[str, str]]: + return list(item.get("directories", [])) # type: ignore[arg-type] + + def _total_file_count(self, items: list[dict[str, object]]) -> int: + return sum(len(self._file_entries(item)) for item in items) + + def _first_file_label(self, items: list[dict[str, object]]) -> str | None: + for item in items: + files = self._file_entries(item) + if files: + return files[0]["label"] + return None + + def _completed_files(self, task_id: str) -> int: + task = self._repository.get_task(task_id) + if not task or task["done_items"] is None: + return 0 + return int(task["done_items"]) + + def _copy_single_planned_file( + self, + task_id: str, + file_entry: dict[str, str], + completed_items: int, + total_items: int, + ) -> int: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=file_entry["label"], + ) + self._filesystem.copy_file(source=file_entry["source"], destination=file_entry["destination"]) + completed_items += 1 + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), + ) + return completed_items + + def _copy_directory_item( + self, + task_id: str, + item: dict[str, object], + completed_items: int, + total_items: int, + cleanup_on_failure: bool = False, + ) -> int: + directories = self._directory_entries(item) + files = self._file_entries(item) + try: + return self._copy_directory_files(directories, files, task_id=task_id, completed_items=completed_items, total_items=total_items) + except Exception: + if cleanup_on_failure: + self._cleanup_partial_duplicate(Path(str(item["destination"]))) + raise + + def _copy_directory_files( + self, + directories: list[dict[str, str]], + files: list[dict[str, str]], + *, + task_id: str | None = None, + completed_items: int = 0, + total_items: int = 0, + ) -> int: + for directory in directories: + Path(directory["destination"]).mkdir(parents=True, exist_ok=True) + for file_entry in files: + if task_id is not None and self._is_cancel_requested(task_id): + return completed_items + if task_id is not None: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=file_entry["label"], + ) + self._filesystem.copy_file(source=file_entry["source"], destination=file_entry["destination"]) + completed_items += 1 + if task_id is not None: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), + ) + if task_id is not None and self._is_cancel_requested(task_id): + return completed_items + for directory in reversed(directories): + shutil.copystat(Path(directory["source"]), Path(directory["destination"]), follow_symlinks=False) + return completed_items + + def _move_single_planned_file( + self, + task_id: str, + file_entry: dict[str, str], + completed_items: int, + total_items: int, + ) -> int: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=file_entry["label"], + ) + self._filesystem.move_file(source=file_entry["source"], destination=file_entry["destination"]) + completed_items += 1 + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), + ) + return completed_items + + def _move_directory_item( + self, + task_id: str, + item: dict[str, object], + completed_items: int, + total_items: int, + ) -> int: + return self._move_directory_files(self._directory_entries(item), self._file_entries(item), task_id=task_id, completed_items=completed_items, total_items=total_items) + + def _move_directory_files( + self, + directories: list[dict[str, str]], + files: list[dict[str, str]], + *, + task_id: str | None = None, + completed_items: int = 0, + total_items: int = 0, + ) -> int: + for directory in directories: + Path(directory["destination"]).mkdir(parents=True, exist_ok=True) + for file_entry in files: + if task_id is not None and self._is_cancel_requested(task_id): + return completed_items + if task_id is not None: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=file_entry["label"], + ) + self._filesystem.move_file(source=file_entry["source"], destination=file_entry["destination"]) + completed_items += 1 + if task_id is not None: + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=self._next_item_label_after_completion(completed_items, total_items, file_entry["label"]), + ) + if task_id is not None and self._is_cancel_requested(task_id): + return completed_items + for directory in reversed(directories): + shutil.copystat(Path(directory["source"]), Path(directory["destination"]), follow_symlinks=False) + for directory in reversed(directories): + self._filesystem.delete_empty_directory(Path(directory["source"])) + return completed_items + + @staticmethod + def _next_item_label_after_completion(completed_items: int, total_items: int, current_label: str) -> str | None: + return None + def _is_cancel_requested(self, task_id: str) -> bool: task = self._repository.get_task(task_id) return bool(task) and task["status"] == "cancelling" @@ -523,18 +683,22 @@ class TaskRunner: task_id: str, done_bytes: int | None, total_bytes: int | None, + done_items: int | None = None, + total_items: int | None = None, ) -> None: if self._is_cancel_requested(task_id): - self._finalize_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes) + self._finalize_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes, done_items=done_items, total_items=total_items) return if self._repository.mark_completed( task_id=task_id, done_bytes=done_bytes, total_bytes=total_bytes, + done_items=done_items, + total_items=total_items, ): self._update_history_completed(task_id) return - self._finalize_if_already_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes) + self._finalize_if_already_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes, done_items=done_items, total_items=total_items) def _complete_or_cancel_item_task( self, diff --git a/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc index 0467993c3eb6f4eb7f23b3ede1afb352fbc3880a..12609fce53f761be4fe909057e05e7d1bcad9cbf 100644 GIT binary patch delta 1178 zcmb7@ZAepL6vsPv^Jn%9l!!Y9@lQY?xCNL)SpF`8K+{P=**rNks^8 zT==E3kW#U7K5DDXB&|pbi-Jgq{8HpMAt6Gd?%4!IOz3|2@tnhX{`b5*XZjVKn*#qI z{{Ft3q;IZ!#QMPLe_00UY)DqFf-$Cu90U9vsRTLxi4CTcOm0#l#0B9SO(F#2jl>je z(kO8tE<~~{NnvQ!Dj@`0wC!r&DqCZ<*(z2y*ylmgpY;=MqNU#BVSZ8Jz~ut&HAkZ% z+JOrh2{@e+fcgY)97zcnjJYH$5N=D?<~a#MkA&XMLOz}Q3B`m~!d=2W0ultmpoCtT zu$9hN2t)XFTP>Vnh4FSdxNtJb=ttth1fd7j$?9CU>^~I;q`+C;YByI|1-HCT?vZDt zu>6na*=;I15H$6vs1DGyW$HVbUWW zlhp>#SYK8LKr_>4XL6iLhRqdPHo0df7r0)WQ4p_-tjRmFe;TY4Fm2(lKIqPe0YN)q1)|o}y>g~d-MQb^rhX delta 1248 zcmaizTS${(7=ZWvJ9r#PX3eE-Dx}lt#B)kcrO>7=or*)lHQkV@bN25iD~d8I!a}My zLjxo4(#Tw2o;tuZL$t!gK-fifnKxzRO?Un8KWP#jzKe(VdwIY2{9RlmuNR2?yIdYB z;?D?7IGR23YvSD%x-J*n=I*pM+jU*`Ca1N-+G1x@ohLeV^p3b*Nm}7kLLBIh#)GOR zg*NZ0BoL%d4%(g?CK8zOv?2AKBzY0-CA@J4 zJB}6;h(W|%#3W)0F%7jDTG?|HUw~7q(0fswLCo^dY_>z_#J^L-cCd5sA%i3LX?5nj zBzl3@)UcS!>EI3*XHVl!az0|@V`k>1Z;2V@#izewvM=#E2eIg61-ypDbb_PD` zRsOjI7D1RiPJ_)s=n?&hz=q`FwGbif$|w{S8Hfk27WD+KYLlcW7B=SkZm~GPG zad;D#a}KR!7~Th$)AW)SM&@?~g9lp44;p;X#6~& tAhO4(c?GT3KG_bbnQUmOv}uXiVV9Ep{@_)GW?7-x5aDgT5H@B*1axj`~ey{(VktvvY@&Olx%@#)1 XER44%mzcd{-JpJj!)kM>`5RdPGN%}% delta 97 zcmZ3!it*tpM&8f7yj%=Gux;|T%r_f(%heg>Hn*y0axf-rey{(Vak7ws0$(t*E;j>% zDI-W71H)uKZH3J|M%FBhFD84Ky%XM|ydhKDyCFB^GX;qcyUZT?0U0L;T5 At^fc4 diff --git a/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_move_golden.cpython-313.pyc index ebda1288a0951747c9b5061adbb4c85055fb3e1a..112cbe9a2e1b6d9a1f5d613e789a8106225b0cb7 100644 GIT binary patch delta 5410 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 delta 5182 zcmai2dvKK170=yQHoHj($!=aGWJ8uD8wer1Q-SbS2qa)Y9ubyh^FbDpUAW%{$P!5a zMPXFLV^!*?aV%<7D)^~XN2~3O9Y>u3M5|pYAWm&lZ3h*nQ`ENicfU<`$pp~p?z!ilZ$G=opdB!zf18$OOA>!C&?^D&iWBKIX39)fmqyYy`U0($8w0J%xbV*K z)x3=%?pK;!8+>7Zi>ocPU2%b-ohu$QHwpQuDd=lcxC5kFZQrW!XbRZ8P|<6mTFZ!{ z6tCCc>Qhy(SA~en1Dux$ z#@L3u9Fo|Ndmx@JcN0vCKOEv6e2kbJw9N!Mmt4)ty28T(i^o<6eA&w;cQ>^1NL(1n0WRGpeX z4qNI&`rFM*2BJfed2yNXj`&YvVv8D)nnukth3Wz58fakL~F83)~^Mb21p0k0UH1oK(YXj zl{-PpU9ck$09pYYum!M9fC|$^YKgyDu{f-wPqW=@fo90LWMDh!uX*d)3T;7t1IyS0 zseORGfZOTlh-qxOc78;=f6$aF)--I%!ta4+C~y3aM%Ng#DzP!9lp zNoQPHMGp!xYE{F&aJ#Cg{2{D73^)S#6&?0EDYtksYoYq$<66eJJ&a!UIM^`q&8Vb< zQ?qG|yPRdva)BLmn|s!L2PKU?Oc&h6v~Wz> zLYZ*4V&OLc8NU}m9S6WX{)m8RYRpEpJ|t4=MNC-&suwA$f$1fBbxes}w;bb>G^^V| z-;deF_G>%FK5FcG60_G@9y1jSy#lUgs-j@{HYpRTP(oznc-)17D0ArtLd=f|@TAJ% zUf=!`;LL{?(r$UZVn$=`WjZ##C*=u{uL;nqCpcNf>lo^h1ob-sk*opp)KI%9!j*9T zS=u_$Gcg{;Ap+QI)UV7Rp$QREjRI)dY6o4Mc#Gv_G59t_d%^|Us!1OhE&9RI_-Wae z*ikN?*3>0WF&+Zx{hLCv59N>lUO;~wu6vj=0`f$0eEe;|O8^|8$0Ci4nl}cx8jf02 z#UBbb^9xuPHXHj-bVf~x{XsicbKK;PXP{X`qSD9T5X@25p$o-q-!rpx(;W z>i~m76hGNnSiMO=3-19Lzt6KVyb&P7sNY;M^rYygZ{N@w@^2AVc{i$hiI2xq`u3e* zjtoEiilc@M1B^&8mU>o=NyzEs z1RF1|a+VI%x~NUt@p{WUdiTuN5z+a=e#^6d?k#zGg z`g&5LE23Z#p7+`-IrW`={-?@qT3D7Vm3PY$!t zv%5Vhmc|S-)D9QrpjeDDszlQQ; zWe<1+3nxVnb?>NsNZD2a-ASKhI3BVcwmrP+OvdQD%%51(4%rXb?@ps7ow@XGL$>zV z`X5;T^VYLr>>NDveGkMlO$?*yTYcdcZ!i@0ZVa^tn?)@coMeJabmY>sBb4r6&7RO& z{8JMYJL$FNoFdsnxUW&pM`KtDD5J~GZ7I)ztPntZT9#4I%CuO5n(0&JlJhG#c@%(- ztG~tiaAQTS)SYleuMcUt?LW%V@?XjlHaWz3Chq|0HCu-6>*Ib_Ue2iYAi( zGrl#bUiu-B_lu$Sw~GCHEkDy0l~KdyUE(Q!Z}aDD`RhVq#1`u#(bnkciEXZFeX6Sx zc&@iKmsPBc3~h((r-Ui$UZJNuiB7aRwG(Y~29D=H!2)NASeHFsAA!vOH;QM>gRn!Tp!$+j^QLg8L8X+mM;XUhUxU?V(rIm zhnW)*;Hg+oaGjo1Jr?OEo}&W#mwFHTM(Yee%JLt; z5qopMA9i)RJfl^Kn`GWKTHvX`Xcdt z<>pLt*_Cp7eBVrSf!T9qI(@#cjxDCD+lz0#Q!t5NggR8d8gerMggI-w_}wV1h>xIT zJV3314f1z2z6%`L;IIVH^oHeGMXg@>%bM34O&0xdsCB!-mub)6KAvUNNNvl7rnx@B zznH$dqnvFb=l;EmQrM)^?#Wk@#Q!U~DXc0ssJfE7$jV9|wtk%JIg{(Tl7!LaH5o~! c)Vm^Qjd`D#Qtw={cgY None: - if Path(source).name == "fail-file.txt": + source_path = Path(source) + if source_path.name == "fail-file.txt" or "fail-dir" in source_path.parts: raise OSError("forced batch move failure") super().move_file(source, destination) @@ -128,6 +129,8 @@ class MoveApiGoldenTest(unittest.TestCase): detail = self._wait_task(body["task_id"]) self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) self.assertTrue((self.root1 / "moved.txt").exists()) self.assertFalse(src.exists()) @@ -269,7 +272,7 @@ class MoveApiGoldenTest(unittest.TestCase): task_id = response.json()["task_id"] self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) running = self._wait_for_status(task_id, {"running"}) - self.assertEqual(running["current_item"], str(self.root1 / "a.txt")) + self.assertEqual(running["current_item"], "a.txt") cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel") self.assertEqual(cancel_response.status_code, 200) @@ -387,8 +390,10 @@ class MoveApiGoldenTest(unittest.TestCase): def test_move_batch_runtime_io_error_failed_task_shape(self) -> None: first = self.root1 / "ok-dir" first.mkdir() + (first / "a.txt").write_text("A", encoding="utf-8") second = self.root1 / "fail-dir" second.mkdir() + (second / "b.txt").write_text("B", encoding="utf-8") target = self.root1 / "target" target.mkdir()