Skip to content

Commit 0eaba92

Browse files
authored
Merge pull request #800 from UiPath/fix/uipath-pull-command
fix: update pull to use coded-evals folder and add conflict detection
2 parents 7290c31 + 101376f commit 0eaba92

File tree

7 files changed

+1028
-35
lines changed

7 files changed

+1028
-35
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.1.128"
3+
version = "2.1.129"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.10"

src/uipath/_cli/_push/sw_file_handler.py

Lines changed: 126 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from .._utils._project_files import ( # type: ignore
2424
FileInfo,
2525
FileOperationUpdate,
26+
InteractiveConflictHandler,
27+
compute_normalized_hash,
2628
files_to_include,
2729
read_toml_project,
2830
)
@@ -56,19 +58,24 @@ def __init__(
5658
project_id: str,
5759
directory: str,
5860
include_uv_lock: bool = True,
61+
conflict_handler: Optional[InteractiveConflictHandler] = None,
5962
) -> None:
6063
"""Initialize the SwFileHandler.
6164
6265
Args:
6366
project_id: The ID of the UiPath project
6467
directory: Local project directory
6568
include_uv_lock: Whether to include uv.lock file
69+
conflict_handler: Optional handler for file conflicts
6670
"""
6771
self.directory = directory
6872
self.include_uv_lock = include_uv_lock
6973
self.console = ConsoleLogger()
7074
self._studio_client = StudioClient(project_id)
7175
self._project_structure: Optional[ProjectStructure] = None
76+
self._conflict_handler = conflict_handler or InteractiveConflictHandler(
77+
operation="push"
78+
)
7279

7380
def _get_folder_by_name(
7481
self, structure: ProjectStructure, folder_name: str
@@ -186,20 +193,78 @@ async def _process_file_uploads(
186193
)
187194

188195
if remote_file:
189-
# File exists remotely - mark for update
196+
# File exists remotely - check if content differs
190197
processed_source_files.add(remote_file.id)
191-
structural_migration.modified_resources.append(
192-
ModifiedResource(
193-
id=remote_file.id, content_file_path=local_file.file_path
198+
199+
# Download remote file and compare with local
200+
try:
201+
remote_response = (
202+
await self._studio_client.download_project_file_async(
203+
remote_file
204+
)
194205
)
195-
)
196-
updates.append(
197-
FileOperationUpdate(
198-
file_path=local_file.file_path,
199-
status="updating",
200-
message=f"Updating '{local_file.file_name}'",
206+
remote_content = remote_response.read().decode("utf-8")
207+
remote_hash = compute_normalized_hash(remote_content)
208+
209+
with open(local_file.file_path, "r", encoding="utf-8") as f:
210+
local_content = f.read()
211+
local_hash = compute_normalized_hash(local_content)
212+
213+
# Only update if content differs and user confirms
214+
if local_hash != remote_hash:
215+
if self._conflict_handler.should_overwrite(
216+
local_file.relative_path,
217+
remote_hash,
218+
local_hash,
219+
local_full_path=os.path.abspath(local_file.file_path),
220+
):
221+
structural_migration.modified_resources.append(
222+
ModifiedResource(
223+
id=remote_file.id,
224+
content_file_path=local_file.file_path,
225+
)
226+
)
227+
updates.append(
228+
FileOperationUpdate(
229+
file_path=local_file.file_path,
230+
status="updating",
231+
message=f"Updating '{local_file.file_name}'",
232+
)
233+
)
234+
else:
235+
updates.append(
236+
FileOperationUpdate(
237+
file_path=local_file.file_path,
238+
status="skipped",
239+
message=f"Skipped '{local_file.file_name}'",
240+
)
241+
)
242+
else:
243+
# Content is the same, no need to update
244+
updates.append(
245+
FileOperationUpdate(
246+
file_path=local_file.file_path,
247+
status="up_to_date",
248+
message=f"File '{local_file.file_name}' is up to date",
249+
)
250+
)
251+
except Exception as e:
252+
logger.warning(
253+
f"Failed to compare file '{local_file.file_path}': {e}"
254+
)
255+
# If comparison fails, proceed with update
256+
structural_migration.modified_resources.append(
257+
ModifiedResource(
258+
id=remote_file.id, content_file_path=local_file.file_path
259+
)
260+
)
261+
updates.append(
262+
FileOperationUpdate(
263+
file_path=local_file.file_path,
264+
status="updating",
265+
message=f"Updating '{local_file.file_name}'",
266+
)
201267
)
202-
)
203268
else:
204269
# File doesn't exist remotely - mark for upload
205270
parent_path = os.path.dirname(local_file.relative_path)
@@ -741,7 +806,7 @@ def _collect_files_from_folder(
741806
files[file.name] = file
742807
return files
743808

744-
def _process_file_sync(
809+
async def _process_file_sync(
745810
self,
746811
local_file_path: str,
747812
remote_files: Dict[str, ProjectFile],
@@ -766,10 +831,51 @@ def _process_file_sync(
766831

767832
if remote_file:
768833
processed_ids.add(remote_file.id)
769-
structural_migration.modified_resources.append(
770-
ModifiedResource(id=remote_file.id, content_file_path=local_file_path)
771-
)
772-
self.console.info(f"Updating {click.style(destination, fg='yellow')}")
834+
835+
# Download remote file and compare with local
836+
try:
837+
remote_response = await self._studio_client.download_project_file_async(
838+
remote_file
839+
)
840+
remote_content = remote_response.read().decode("utf-8")
841+
remote_hash = compute_normalized_hash(remote_content)
842+
843+
with open(local_file_path, "r", encoding="utf-8") as f:
844+
local_content = f.read()
845+
local_hash = compute_normalized_hash(local_content)
846+
847+
# Only update if content differs and user confirms
848+
if local_hash != remote_hash:
849+
if self._conflict_handler.should_overwrite(
850+
destination,
851+
remote_hash,
852+
local_hash,
853+
local_full_path=os.path.abspath(local_file_path),
854+
):
855+
structural_migration.modified_resources.append(
856+
ModifiedResource(
857+
id=remote_file.id, content_file_path=local_file_path
858+
)
859+
)
860+
self.console.info(
861+
f"Updating {click.style(destination, fg='yellow')}"
862+
)
863+
else:
864+
self.console.info(
865+
f"Skipped {click.style(destination, fg='bright_black')}"
866+
)
867+
else:
868+
# Content is the same, no need to update
869+
self.console.info(f"File '{destination}' is up to date")
870+
except Exception as e:
871+
logger.warning(f"Failed to compare file '{local_file_path}': {e}")
872+
# If comparison fails, proceed with update
873+
structural_migration.modified_resources.append(
874+
ModifiedResource(
875+
id=remote_file.id, content_file_path=local_file_path
876+
)
877+
)
878+
self.console.info(f"Updating {click.style(destination, fg='yellow')}")
773879
else:
774880
structural_migration.added_resources.append(
775881
AddedResource(
@@ -870,7 +976,7 @@ async def upload_coded_evals_files(self) -> None:
870976
register_evaluator(evaluator.custom_evaluator_file_name)
871977
)
872978

873-
self._process_file_sync(
979+
await self._process_file_sync(
874980
evaluator_schema_file_path,
875981
remote_custom_evaluator_files,
876982
"coded-evals/evaluators/custom",
@@ -879,7 +985,7 @@ async def upload_coded_evals_files(self) -> None:
879985
processed_custom_evaluator_ids,
880986
)
881987

882-
self._process_file_sync(
988+
await self._process_file_sync(
883989
evaluator_types_file_path,
884990
remote_custom_evaluator_type_files,
885991
"coded-evals/evaluators/custom/types",
@@ -888,7 +994,7 @@ async def upload_coded_evals_files(self) -> None:
888994
processed_evaluator_type_ids,
889995
)
890996

891-
self._process_file_sync(
997+
await self._process_file_sync(
892998
evaluator.path,
893999
remote_evaluator_files,
8941000
"coded-evals/evaluators",
@@ -898,7 +1004,7 @@ async def upload_coded_evals_files(self) -> None:
8981004
)
8991005

9001006
for eval_set_file in eval_set_files:
901-
self._process_file_sync(
1007+
await self._process_file_sync(
9021008
eval_set_file,
9031009
remote_eval_set_files,
9041010
"coded-evals/eval-sets",

src/uipath/_cli/_utils/_project_files.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ class FileConflictHandler(Protocol):
5757
"""Protocol for handling file conflicts."""
5858

5959
def should_overwrite(
60-
self, file_path: str, local_hash: str, remote_hash: str
60+
self,
61+
file_path: str,
62+
local_hash: str,
63+
remote_hash: str,
64+
local_full_path: Optional[Path] = None,
6165
) -> bool:
6266
"""Return True to overwrite, False to skip."""
6367
...
@@ -67,7 +71,11 @@ class AlwaysOverwriteHandler:
6771
"""Handler that always overwrites files."""
6872

6973
def should_overwrite(
70-
self, file_path: str, local_hash: str, remote_hash: str
74+
self,
75+
file_path: str,
76+
local_hash: str,
77+
remote_hash: str,
78+
local_full_path: Optional[Path] = None,
7179
) -> bool:
7280
return True
7381

@@ -76,11 +84,73 @@ class AlwaysSkipHandler:
7684
"""Handler that always skips conflicts."""
7785

7886
def should_overwrite(
79-
self, file_path: str, local_hash: str, remote_hash: str
87+
self,
88+
file_path: str,
89+
local_hash: str,
90+
remote_hash: str,
91+
local_full_path: Optional[Path] = None,
8092
) -> bool:
8193
return False
8294

8395

96+
class InteractiveConflictHandler:
97+
"""Handler that prompts the user for each conflict during pull or push operations.
98+
99+
Attributes:
100+
operation: The operation type - either "pull" or "push"
101+
"""
102+
103+
def __init__(self, operation: Literal["pull", "push"]) -> None:
104+
"""Initialize the handler with an operation type.
105+
106+
Args:
107+
operation: Either "pull" or "push" to determine the prompt message
108+
"""
109+
self.operation = operation
110+
111+
def should_overwrite(
112+
self,
113+
file_path: str,
114+
local_hash: str,
115+
remote_hash: str,
116+
local_full_path: Optional[Path] = None,
117+
) -> bool:
118+
"""Ask the user if they want to overwrite based on the operation.
119+
120+
Args:
121+
file_path: Relative path to the file (for display)
122+
local_hash: Hash of the local file content
123+
remote_hash: Hash of the remote file content
124+
local_full_path: Full path to the local file (unused, kept for protocol compatibility)
125+
126+
Returns:
127+
True if the user wants to overwrite, False otherwise
128+
"""
129+
import sys
130+
131+
import click
132+
133+
click.echo(
134+
click.style(
135+
f"\nFile '{file_path}' has different content on remote.",
136+
fg="yellow",
137+
)
138+
)
139+
140+
# Prompt user for confirmation with operation-specific message
141+
sys.stdout.flush()
142+
if self.operation == "pull":
143+
return click.confirm(
144+
"Do you want to overwrite the local file with the remote version?",
145+
default=False,
146+
)
147+
else: # push
148+
return click.confirm(
149+
"Do you want to push and overwrite the remote file with your local version?",
150+
default=False,
151+
)
152+
153+
84154
class FileInfo(BaseModel):
85155
"""Information about a file to be included in the project.
86156
@@ -608,7 +678,7 @@ async def download_folder_files(
608678

609679
if local_hash != remote_hash:
610680
if conflict_handler.should_overwrite(
611-
file_path, local_hash, remote_hash
681+
file_path, local_hash, remote_hash, local_path
612682
):
613683
with open(local_path, "w", encoding="utf-8", newline="\n") as f:
614684
f.write(remote_content)

src/uipath/_cli/cli_pull.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
44
This module provides functionality to pull remote project files from a UiPath StudioWeb solution.
55
It handles:
6-
- File downloads from source_code and evals folders
6+
- File downloads from source_code and coded-evals folders
77
- Maintaining folder structure locally
88
- File comparison using hashes
99
- Interactive confirmation for overwriting files
@@ -18,7 +18,11 @@
1818
from .._config import UiPathConfig
1919
from ..telemetry import track
2020
from ._utils._console import ConsoleLogger
21-
from ._utils._project_files import ProjectPullError, pull_project
21+
from ._utils._project_files import (
22+
InteractiveConflictHandler,
23+
ProjectPullError,
24+
pull_project,
25+
)
2226

2327
console = ConsoleLogger()
2428

@@ -34,7 +38,7 @@ def pull(root: Path) -> None:
3438
"""Pull remote project files from Studio Web Project.
3539
3640
This command pulls the remote project files from a UiPath Studio Web project.
37-
It downloads files from the source_code and evals folders, maintaining the
41+
It downloads files from the source_code and coded-evals folders, maintaining the
3842
folder structure locally. Files are compared using hashes before overwriting,
3943
and user confirmation is required for differing files.
4044
@@ -54,13 +58,18 @@ def pull(root: Path) -> None:
5458

5559
download_configuration = {
5660
"source_code": root,
57-
"evals": root / "evals",
61+
"coded-evals": root / "evals",
5862
}
5963

64+
# Create interactive conflict handler for user confirmation
65+
conflict_handler = InteractiveConflictHandler(operation="pull")
66+
6067
try:
6168

6269
async def run_pull():
63-
async for update in pull_project(project_id, download_configuration):
70+
async for update in pull_project(
71+
project_id, download_configuration, conflict_handler
72+
):
6473
console.info(f"Processing: {update.file_path}")
6574
console.info(update.message)
6675

0 commit comments

Comments
 (0)