Skip to content

Commit c67c34b

Browse files
committed
feat: cli add pull command
1 parent 8269249 commit c67c34b

File tree

8 files changed

+761
-84
lines changed

8 files changed

+761
-84
lines changed

src/uipath/_cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .cli_new import new as new # type: ignore
1111
from .cli_pack import pack as pack # type: ignore
1212
from .cli_publish import publish as publish # type: ignore
13+
from .cli_pull import pull as pull # type: ignore
1314
from .cli_push import push as push # type: ignore
1415
from .cli_run import run as run # type: ignore
1516

@@ -65,3 +66,4 @@ def cli(lv: bool, v: bool) -> None:
6566
cli.add_command(auth)
6667
cli.add_command(invoke)
6768
cli.add_command(push)
69+
cli.add_command(pull)

src/uipath/_cli/_utils/_common.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
from typing import Optional
3+
from urllib.parse import urlparse
34

45
import click
56

@@ -29,7 +30,7 @@ def environment_options(function):
2930
return function
3031

3132

32-
def get_env_vars(spinner: Optional[Spinner] = None) -> list[str | None]:
33+
def get_env_vars(spinner: Optional[Spinner] = None) -> list[str]:
3334
base_url = os.environ.get("UIPATH_URL")
3435
token = os.environ.get("UIPATH_ACCESS_TOKEN")
3536

@@ -42,7 +43,8 @@ def get_env_vars(spinner: Optional[Spinner] = None) -> list[str | None]:
4243
click.echo("UIPATH_URL, UIPATH_ACCESS_TOKEN")
4344
click.get_current_context().exit(1)
4445

45-
return [base_url, token]
46+
# at this step we know for sure that both base_url and token exist. type checking can be disabled
47+
return [base_url, token] # type: ignore
4648

4749

4850
def serialize_object(obj):
@@ -69,3 +71,18 @@ def serialize_object(obj):
6971
# Return primitive types as is
7072
else:
7173
return obj
74+
75+
76+
def get_org_scoped_url(base_url: str) -> str:
77+
"""Get organization scoped URL from base URL.
78+
79+
Args:
80+
base_url: The base URL to scope
81+
82+
Returns:
83+
str: The organization scoped URL
84+
"""
85+
parsed = urlparse(base_url)
86+
org_name, *_ = parsed.path.strip("/").split("/")
87+
org_scoped_url = f"{parsed.scheme}://{parsed.netloc}/{org_name}"
88+
return org_scoped_url

src/uipath/_cli/_utils/_studio_project.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from typing import List, Optional, Union
1+
from typing import Any, List, Optional, Union
22

3+
import httpx
34
from pydantic import BaseModel, ConfigDict, Field, field_validator
45

56

@@ -136,3 +137,62 @@ def convert_folder_type(cls, v: Union[str, int, None]) -> Optional[str]:
136137
if isinstance(v, int):
137138
return str(v)
138139
return v
140+
141+
142+
def get_project_structure(
143+
project_id: str,
144+
base_url: str,
145+
token: str,
146+
client: httpx.Client,
147+
) -> ProjectStructure:
148+
"""Retrieve the project's file structure from UiPath Cloud.
149+
150+
Makes an API call to fetch the complete file structure of a project,
151+
including all files and folders. The response is validated against
152+
the ProjectStructure model.
153+
154+
Args:
155+
project_id: The ID of the project
156+
base_url: The base URL for the API
157+
token: Authentication token
158+
client: HTTP client to use for requests
159+
160+
Returns:
161+
ProjectStructure: The complete project structure
162+
163+
Raises:
164+
httpx.HTTPError: If the API request fails
165+
"""
166+
headers = {"Authorization": f"Bearer {token}"}
167+
url = (
168+
f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/Structure"
169+
)
170+
171+
response = client.get(url, headers=headers)
172+
response.raise_for_status()
173+
return ProjectStructure.model_validate(response.json())
174+
175+
176+
def download_file(base_url: str, file_id: str, client: httpx.Client, token: str) -> Any:
177+
file_url = f"{base_url}/File/{file_id}"
178+
response = client.get(file_url, headers={"Authorization": f"Bearer {token}"})
179+
response.raise_for_status()
180+
return response
181+
182+
183+
def get_folder_by_name(
184+
structure: ProjectStructure, folder_name: str
185+
) -> Optional[ProjectFolder]:
186+
"""Get a folder from the project structure by name.
187+
188+
Args:
189+
structure: The project structure
190+
folder_name: Name of the folder to find
191+
192+
Returns:
193+
Optional[ProjectFolder]: The found folder or None
194+
"""
195+
for folder in structure.folders:
196+
if folder.name == folder_name:
197+
return folder
198+
return None

src/uipath/_cli/cli_pull.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# type: ignore
2+
"""CLI command for pulling remote project files from UiPath StudioWeb solution.
3+
4+
This module provides functionality to pull remote project files from a UiPath StudioWeb solution.
5+
It handles:
6+
- File downloads from source_code and evals folders
7+
- Maintaining folder structure locally
8+
- File comparison using hashes
9+
- Interactive confirmation for overwriting files
10+
"""
11+
12+
# type: ignore
13+
import hashlib
14+
import json
15+
import os
16+
from typing import Dict, Set
17+
18+
import click
19+
import httpx
20+
from dotenv import load_dotenv
21+
22+
from .._utils._ssl_context import get_httpx_client_kwargs
23+
from ..telemetry import track
24+
from ._utils._common import get_env_vars, get_org_scoped_url
25+
from ._utils._console import ConsoleLogger
26+
from ._utils._studio_project import (
27+
ProjectFile,
28+
ProjectFolder,
29+
download_file,
30+
get_folder_by_name,
31+
get_project_structure,
32+
)
33+
34+
console = ConsoleLogger()
35+
load_dotenv(override=True)
36+
37+
38+
def compute_normalized_hash(content: str) -> str:
39+
"""Compute hash of normalized content.
40+
41+
Args:
42+
content: Content to hash
43+
44+
Returns:
45+
str: SHA256 hash of the normalized content
46+
"""
47+
try:
48+
# Try to parse as JSON to handle formatting
49+
json_content = json.loads(content)
50+
normalized = json.dumps(json_content, indent=2)
51+
except json.JSONDecodeError:
52+
# Not JSON, normalize line endings
53+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
54+
55+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
56+
57+
58+
def collect_files_from_folder(
59+
folder: ProjectFolder, base_path: str, files_dict: Dict[str, ProjectFile]
60+
) -> None:
61+
"""Recursively collect all files from a folder and its subfolders.
62+
63+
Args:
64+
folder: The folder to collect files from
65+
base_path: Base path for file paths
66+
files_dict: Dictionary to store collected files
67+
"""
68+
# Add files from current folder
69+
for file in folder.files:
70+
file_path = os.path.join(base_path, file.name)
71+
files_dict[file_path] = file
72+
73+
# Recursively process subfolders
74+
for subfolder in folder.folders:
75+
subfolder_path = os.path.join(base_path, subfolder.name)
76+
collect_files_from_folder(subfolder, subfolder_path, files_dict)
77+
78+
79+
def download_folder_files(
80+
folder: ProjectFolder,
81+
base_path: str,
82+
base_url: str,
83+
token: str,
84+
client: httpx.Client,
85+
processed_files: Set[str],
86+
) -> None:
87+
"""Download files from a folder recursively.
88+
89+
Args:
90+
folder: The folder to download files from
91+
base_path: Base path for local file storage
92+
base_url: Base URL for API requests
93+
token: Authentication token
94+
client: HTTP client
95+
processed_files: Set to track processed files
96+
"""
97+
files_dict: Dict[str, ProjectFile] = {}
98+
collect_files_from_folder(folder, "", files_dict)
99+
100+
for file_path, remote_file in files_dict.items():
101+
local_path = os.path.join(base_path, file_path)
102+
local_dir = os.path.dirname(local_path)
103+
104+
# Create directory if it doesn't exist
105+
if not os.path.exists(local_dir):
106+
os.makedirs(local_dir)
107+
108+
# Download remote file
109+
response = download_file(base_url, remote_file.id, client, token)
110+
remote_content = response.read().decode("utf-8")
111+
remote_hash = compute_normalized_hash(remote_content)
112+
113+
if os.path.exists(local_path):
114+
# Read and hash local file
115+
with open(local_path, "r", encoding="utf-8") as f:
116+
local_content = f.read()
117+
local_hash = compute_normalized_hash(local_content)
118+
119+
# Compare hashes
120+
if local_hash != remote_hash:
121+
styled_path = click.style(str(file_path), fg="cyan")
122+
console.warning(f"File {styled_path}" + " differs from remote version.")
123+
response = click.prompt("Do you want to override it? (y/n)", type=str)
124+
if response.lower() == "y":
125+
with open(local_path, "w", encoding="utf-8", newline="\n") as f:
126+
f.write(remote_content)
127+
console.success(f"Updated {click.style(str(file_path), fg='cyan')}")
128+
else:
129+
console.info(f"Skipped {click.style(str(file_path), fg='cyan')}")
130+
else:
131+
console.info(
132+
f"File {click.style(str(file_path), fg='cyan')} is up to date"
133+
)
134+
else:
135+
# File doesn't exist locally, create it
136+
with open(local_path, "w", encoding="utf-8", newline="\n") as f:
137+
f.write(remote_content)
138+
console.success(f"Downloaded {click.style(str(file_path), fg='cyan')}")
139+
140+
processed_files.add(file_path)
141+
142+
143+
@click.command()
144+
@click.argument("root", type=str, default="./")
145+
@track
146+
def pull(root: str) -> None:
147+
"""Pull remote project files from Studio Web Project.
148+
149+
This command pulls the remote project files from a UiPath Studio Web project.
150+
It downloads files from the source_code and evals folders, maintaining the
151+
folder structure locally. Files are compared using hashes before overwriting,
152+
and user confirmation is required for differing files.
153+
154+
Args:
155+
root: The root directory to pull files into
156+
157+
Environment Variables:
158+
UIPATH_PROJECT_ID: Required. The ID of the UiPath Studio Web project
159+
160+
Example:
161+
$ uipath pull
162+
$ uipath pull /path/to/project
163+
"""
164+
if not os.getenv("UIPATH_PROJECT_ID", False):
165+
console.error("UIPATH_PROJECT_ID environment variable not found.")
166+
167+
[base_url, token] = get_env_vars()
168+
base_api_url = f"{get_org_scoped_url(base_url)}/studio_/backend/api/Project/{os.getenv('UIPATH_PROJECT_ID')}/FileOperations"
169+
170+
with console.spinner("Pulling UiPath project files..."):
171+
try:
172+
with httpx.Client(**get_httpx_client_kwargs()) as client:
173+
# Get project structure
174+
structure = get_project_structure(
175+
os.getenv("UIPATH_PROJECT_ID"), # type: ignore
176+
get_org_scoped_url(base_url),
177+
token,
178+
client,
179+
)
180+
181+
processed_files: Set[str] = set()
182+
183+
# Process source_code folder
184+
source_code_folder = get_folder_by_name(structure, "source_code")
185+
if source_code_folder:
186+
download_folder_files(
187+
source_code_folder,
188+
root,
189+
base_api_url,
190+
token,
191+
client,
192+
processed_files,
193+
)
194+
else:
195+
console.warning("No source_code folder found in remote project")
196+
197+
# Process evals folder
198+
evals_folder = get_folder_by_name(structure, "evals")
199+
if evals_folder:
200+
evals_path = os.path.join(root, "evals")
201+
download_folder_files(
202+
evals_folder,
203+
evals_path,
204+
base_api_url,
205+
token,
206+
client,
207+
processed_files,
208+
)
209+
else:
210+
console.warning("No evals folder found in remote project")
211+
212+
except Exception as e:
213+
console.error(f"Failed to pull UiPath project: {str(e)}")

0 commit comments

Comments
 (0)