Skip to content

Commit a22dd6a

Browse files
committed
feat: cli add push command
1 parent 7035c31 commit a22dd6a

10 files changed

Lines changed: 3005 additions & 1520 deletions

File tree

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_push import push as push # type: ignore
1314
from .cli_run import run as run # type: ignore
1415

1516

@@ -63,3 +64,4 @@ def cli(lv: bool, v: bool) -> None:
6364
cli.add_command(deploy)
6465
cli.add_command(auth)
6566
cli.add_command(invoke)
67+
cli.add_command(push)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
BINDINGS_VERSION = "2.2"
2+
3+
# Agent.json constants
4+
AGENT_VERSION = "1.0.0"
5+
AGENT_STORAGE_VERSION = "1.0.0"
6+
AGENT_INITIAL_CODE_VERSION = "1.0.0"
7+
AGENT_TARGET_RUNTIME = "python"
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
# type: ignore
2+
import json
3+
import os
4+
import re
5+
from typing import Any, Dict, Optional, Tuple
6+
7+
from pydantic import BaseModel
8+
9+
from .._utils._console import ConsoleLogger
10+
11+
try:
12+
import tomllib
13+
except ImportError:
14+
import tomli as tomllib
15+
16+
17+
class FileInfo(BaseModel):
18+
"""Information about a file to be included in the project.
19+
20+
Attributes:
21+
file_path: The absolute path to the file
22+
relative_path: The path relative to the project root
23+
is_binary: Whether the file should be treated as binary
24+
"""
25+
26+
file_path: str
27+
relative_path: str
28+
is_binary: bool
29+
30+
31+
console = ConsoleLogger()
32+
33+
34+
def get_project_config(directory: str) -> dict[str, str]:
35+
"""Retrieve and combine project configuration from uipath.json and pyproject.toml.
36+
37+
Args:
38+
directory: The root directory containing the configuration files
39+
40+
Returns:
41+
dict[str, str]: Combined configuration including project name, description,
42+
entry points, version, and authors
43+
44+
Raises:
45+
SystemExit: If required configuration files are missing or invalid
46+
"""
47+
config_path = os.path.join(directory, "uipath.json")
48+
toml_path = os.path.join(directory, "pyproject.toml")
49+
50+
if not os.path.isfile(config_path):
51+
console.error("uipath.json not found, please run `uipath init`.")
52+
if not os.path.isfile(toml_path):
53+
console.error("pyproject.toml not found.")
54+
55+
with open(config_path, "r") as config_file:
56+
config_data = json.load(config_file)
57+
58+
validate_config_structure(config_data)
59+
60+
toml_data = read_toml_project(toml_path)
61+
62+
return {
63+
"project_name": toml_data["name"],
64+
"description": toml_data["description"],
65+
"entryPoints": config_data["entryPoints"],
66+
"version": toml_data["version"],
67+
"authors": toml_data["authors"],
68+
"dependencies": toml_data.get("dependencies", {}),
69+
}
70+
71+
72+
def validate_config(config: dict[str, str]) -> None:
73+
"""Validate the combined project configuration.
74+
75+
Checks for required fields and invalid characters in project name and description.
76+
77+
Args:
78+
config: The combined configuration dictionary from uipath.json and pyproject.toml
79+
80+
Raises:
81+
SystemExit: If validation fails for any required field or contains invalid characters
82+
"""
83+
if not config["project_name"] or config["project_name"].strip() == "":
84+
console.error(
85+
"Project name cannot be empty. Please specify a name in pyproject.toml."
86+
)
87+
88+
if not config["description"] or config["description"].strip() == "":
89+
console.error(
90+
"Project description cannot be empty. Please specify a description in pyproject.toml."
91+
)
92+
93+
if not config["authors"] or config["authors"].strip() == "":
94+
console.error(
95+
'Project authors cannot be empty. Please specify authors in pyproject.toml:\n authors = [{ name = "John Doe" }]'
96+
)
97+
98+
invalid_chars = ["&", "<", ">", '"', "'", ";"]
99+
for char in invalid_chars:
100+
if char in config["project_name"]:
101+
console.error(f"Project name contains invalid character: '{char}'")
102+
103+
for char in invalid_chars:
104+
if char in config["description"]:
105+
console.error(f"Project description contains invalid character: '{char}'")
106+
107+
108+
def validate_config_structure(config_data: dict[str, Any]) -> None:
109+
"""Validate the structure of uipath.json configuration.
110+
111+
Args:
112+
config_data: The raw configuration data from uipath.json
113+
114+
Raises:
115+
SystemExit: If required fields are missing from the configuration
116+
"""
117+
required_fields = ["entryPoints"]
118+
for field in required_fields:
119+
if field not in config_data:
120+
console.error(f"uipath.json is missing the required field: {field}.")
121+
122+
123+
def ensure_config_file(directory: str) -> None:
124+
"""Check if uipath.json exists in the specified directory.
125+
126+
Args:
127+
directory: The directory to check for uipath.json
128+
129+
Raises:
130+
SystemExit: If uipath.json is not found in the directory
131+
"""
132+
if not os.path.isfile(os.path.join(directory, "uipath.json")):
133+
console.error(
134+
"uipath.json not found. Please run `uipath init` in the project directory."
135+
)
136+
137+
138+
def extract_dependencies_from_toml(project_data: Dict) -> Dict[str, str]:
139+
"""Extract and parse dependencies from pyproject.toml project data.
140+
141+
Args:
142+
project_data: The "project" section from pyproject.toml
143+
144+
Returns:
145+
Dictionary mapping package names to version specifiers
146+
"""
147+
dependencies = {}
148+
149+
if "dependencies" not in project_data:
150+
return dependencies
151+
152+
deps_list = project_data["dependencies"]
153+
if not isinstance(deps_list, list):
154+
console.warning("dependencies should be a list in pyproject.toml")
155+
return dependencies
156+
157+
for dep in deps_list:
158+
if not isinstance(dep, str):
159+
console.warning(f"Skipping non-string dependency: {dep}")
160+
continue
161+
162+
try:
163+
name, version_spec = parse_dependency_string(dep)
164+
if name: # Only add if we got a valid name
165+
dependencies[name] = version_spec
166+
except Exception as e:
167+
console.warning(f"Failed to parse dependency '{dep}': {e}")
168+
continue
169+
170+
return dependencies
171+
172+
173+
def parse_dependency_string(dependency: str) -> Tuple[str, str]:
174+
"""Parse a dependency string into package name and version specifier.
175+
176+
Handles PEP 508 dependency specifications including:
177+
- Simple names: "requests"
178+
- Version specifiers: "requests>=2.28.0"
179+
- Complex specifiers: "requests>=2.28.0,<3.0.0"
180+
- Extras: "requests[security]>=2.28.0"
181+
- Environment markers: "requests>=2.28.0; python_version>='3.8'"
182+
183+
Args:
184+
dependency: Raw dependency string from pyproject.toml
185+
186+
Returns:
187+
Tuple of (package_name, version_specifier)
188+
189+
Examples:
190+
"requests" -> ("requests", "*")
191+
"requests>=2.28.0" -> ("requests", ">=2.28.0")
192+
"requests>=2.28.0,<3.0.0" -> ("requests", ">=2.28.0,<3.0.0")
193+
"requests[security]>=2.28.0" -> ("requests", ">=2.28.0")
194+
"""
195+
# Remove whitespace
196+
dependency = dependency.strip()
197+
198+
# Handle environment markers (everything after semicolon)
199+
if ";" in dependency:
200+
dependency = dependency.split(";")[0].strip()
201+
202+
# Pattern to match package name with optional extras and version specifiers
203+
# Matches: package_name[extras] version_specs
204+
pattern = r"^([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)(\[[^\]]+\])?(.*)"
205+
match = re.match(pattern, dependency)
206+
207+
if not match:
208+
# Fallback for edge cases
209+
return dependency, "*"
210+
211+
package_name = match.group(1)
212+
version_part = match.group(4).strip() if match.group(4) else ""
213+
214+
# If no version specifier, return wildcard
215+
if not version_part:
216+
return package_name, "*"
217+
218+
# Clean up version specifier
219+
version_spec = version_part.strip()
220+
221+
# Validate that version specifier starts with a valid operator
222+
valid_operators = [">=", "<=", "==", "!=", "~=", ">", "<"]
223+
if not any(version_spec.startswith(op) for op in valid_operators):
224+
# If it doesn't start with an operator, treat as exact version
225+
if version_spec:
226+
version_spec = f"=={version_spec}"
227+
else:
228+
version_spec = "*"
229+
230+
return package_name, version_spec
231+
232+
233+
def read_toml_project(file_path: str) -> dict:
234+
"""Read and parse pyproject.toml file with improved error handling and validation.
235+
236+
Args:
237+
file_path: Path to pyproject.toml file
238+
239+
Returns:
240+
Dictionary containing project metadata and dependencies
241+
"""
242+
try:
243+
with open(file_path, "rb") as f:
244+
content = tomllib.load(f)
245+
except Exception as e:
246+
console.error(f"Failed to read or parse pyproject.toml: {e}")
247+
248+
# Validate required sections
249+
if "project" not in content:
250+
console.error("pyproject.toml is missing the required field: project.")
251+
252+
project = content["project"]
253+
254+
# Validate required fields with better error messages
255+
required_fields = {
256+
"name": "Project name is required in pyproject.toml",
257+
"description": "Project description is required in pyproject.toml",
258+
"version": "Project version is required in pyproject.toml",
259+
}
260+
261+
for field, error_msg in required_fields.items():
262+
if field not in project:
263+
console.error(
264+
f"pyproject.toml is missing the required field: project.{field}. {error_msg}"
265+
)
266+
267+
# Check for empty values only if field exists
268+
if field in project and (
269+
not project[field]
270+
or (isinstance(project[field], str) and not project[field].strip())
271+
):
272+
console.error(
273+
f"Project {field} cannot be empty. Please specify a {field} in pyproject.toml."
274+
)
275+
276+
# Extract author information safely
277+
authors = project.get("authors", [])
278+
author_name = ""
279+
280+
if authors and isinstance(authors, list) and len(authors) > 0:
281+
first_author = authors[0]
282+
if isinstance(first_author, dict):
283+
author_name = first_author.get("name", "")
284+
elif isinstance(first_author, str):
285+
# Handle case where authors is a list of strings
286+
author_name = first_author
287+
288+
# Extract dependencies with improved parsing
289+
dependencies = extract_dependencies_from_toml(project)
290+
291+
return {
292+
"name": project["name"].strip(),
293+
"description": project["description"].strip(),
294+
"version": project["version"].strip(),
295+
"authors": author_name.strip(),
296+
"dependencies": dependencies,
297+
}
298+
299+
300+
def files_to_include(
301+
config_data: Optional[dict[Any, Any]], directory: str
302+
) -> list[FileInfo]:
303+
"""Get list of files to include in the project based on configuration.
304+
305+
Walks through the directory tree and identifies files to include based on extensions
306+
and explicit inclusion rules. Skips virtual environments and hidden directories.
307+
308+
Args:
309+
settings_section: Configuration section containing file inclusion rules
310+
directory: Root directory to search for files
311+
312+
Returns:
313+
list[FileInfo]: List of file information objects for included files
314+
"""
315+
file_extensions_included = [".py", ".mermaid", ".json", ".yaml", ".yml"]
316+
files_included = []
317+
binary_extensions = [".exe", "", ".xlsx", ".xls"]
318+
if "settings" in config_data:
319+
settings = config_data["settings"]
320+
if "fileExtensionsIncluded" in settings:
321+
file_extensions_included.extend(settings["fileExtensionsIncluded"])
322+
if "filesIncluded" in settings:
323+
files_included = settings["filesIncluded"]
324+
325+
def is_venv_dir(d: str) -> bool:
326+
"""Check if a directory is a Python virtual environment.
327+
328+
Args:
329+
d: Directory path to check
330+
331+
Returns:
332+
bool: True if directory is a virtual environment, False otherwise
333+
"""
334+
return (
335+
os.path.exists(os.path.join(d, "Scripts", "activate"))
336+
if os.name == "nt"
337+
else os.path.exists(os.path.join(d, "bin", "activate"))
338+
)
339+
340+
extra_files: list[FileInfo] = []
341+
# Walk through directory and return all files in the allowlist
342+
for root, dirs, files in os.walk(directory):
343+
# Skip all directories that start with . or are a venv
344+
dirs[:] = [
345+
d
346+
for d in dirs
347+
if not d.startswith(".") and not is_venv_dir(os.path.join(root, d))
348+
]
349+
for file in files:
350+
file_extension = os.path.splitext(file)[1].lower()
351+
if file_extension in file_extensions_included or file in files_included:
352+
file_path = os.path.join(root, file)
353+
rel_path = os.path.relpath(file_path, directory)
354+
extra_files.append(
355+
FileInfo(
356+
file_path=file_path,
357+
relative_path=rel_path,
358+
is_binary=file_extension in binary_extensions,
359+
)
360+
)
361+
return extra_files

0 commit comments

Comments
 (0)