Skip to content

Commit b1ff4fc

Browse files
authored
Merge pull request #479 from UiPath/feat/cli-pull
feat: CLI add pull command
2 parents 4ea7e77 + e30b063 commit b1ff4fc

11 files changed

Lines changed: 725 additions & 12 deletions

File tree

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.6"
3+
version = "2.1.7"
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/__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_pack.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@ def display_project_info(config):
276276

277277

278278
@click.command()
279-
@click.argument("root", type=str, default="./")
279+
@click.argument(
280+
"root", type=click.Path(exists=True, file_okay=False, dir_okay=True), default="."
281+
)
280282
@click.option(
281283
"--nolock",
282284
is_flag=True,

src/uipath/_cli/cli_pull.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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(
145+
"root", type=click.Path(exists=True, file_okay=False, dir_okay=True), default="."
146+
)
147+
@track
148+
def pull(root: str) -> None:
149+
"""Pull remote project files from Studio Web Project.
150+
151+
This command pulls the remote project files from a UiPath Studio Web project.
152+
It downloads files from the source_code and evals folders, maintaining the
153+
folder structure locally. Files are compared using hashes before overwriting,
154+
and user confirmation is required for differing files.
155+
156+
Args:
157+
root: The root directory to pull files into
158+
159+
Environment Variables:
160+
UIPATH_PROJECT_ID: Required. The ID of the UiPath Studio Web project
161+
162+
Example:
163+
$ uipath pull
164+
$ uipath pull /path/to/project
165+
"""
166+
if not os.getenv("UIPATH_PROJECT_ID", False):
167+
console.error("UIPATH_PROJECT_ID environment variable not found.")
168+
169+
[base_url, token] = get_env_vars()
170+
base_api_url = f"{get_org_scoped_url(base_url)}/studio_/backend/api/Project/{os.getenv('UIPATH_PROJECT_ID')}/FileOperations"
171+
172+
with console.spinner("Pulling UiPath project files..."):
173+
try:
174+
with httpx.Client(**get_httpx_client_kwargs()) as client:
175+
# Get project structure
176+
structure = get_project_structure(
177+
os.getenv("UIPATH_PROJECT_ID"), # type: ignore
178+
get_org_scoped_url(base_url),
179+
token,
180+
client,
181+
)
182+
183+
processed_files: Set[str] = set()
184+
185+
# Process source_code folder
186+
source_code_folder = get_folder_by_name(structure, "source_code")
187+
if source_code_folder:
188+
download_folder_files(
189+
source_code_folder,
190+
root,
191+
base_api_url,
192+
token,
193+
client,
194+
processed_files,
195+
)
196+
else:
197+
console.warning("No source_code folder found in remote project")
198+
199+
# Process evals folder
200+
evals_folder = get_folder_by_name(structure, "evals")
201+
if evals_folder:
202+
evals_path = os.path.join(root, "evals")
203+
download_folder_files(
204+
evals_folder,
205+
evals_path,
206+
base_api_url,
207+
token,
208+
client,
209+
processed_files,
210+
)
211+
else:
212+
console.warning("No evals folder found in remote project")
213+
214+
except Exception as e:
215+
console.error(f"Failed to pull UiPath project: {str(e)}")

src/uipath/_cli/cli_push.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,9 @@ def upload_source_files_to_project(
492492

493493

494494
@click.command()
495-
@click.argument("root", type=str, default="./")
495+
@click.argument(
496+
"root", type=click.Path(exists=True, file_okay=False, dir_okay=True), default="."
497+
)
496498
@click.option(
497499
"--nolock",
498500
is_flag=True,

0 commit comments

Comments
 (0)