Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath"
version = "2.14.0"
version = "2.14.1"
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
108 changes: 79 additions & 29 deletions packages/uipath/src/uipath/_cli/cli_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ._utils._common import determine_project_type
from ._utils._console import ConsoleLogger
from ._utils._project_files import (
FileInfo,
ensure_config_file,
files_to_include,
get_project_config,
Expand All @@ -35,6 +36,12 @@

schema = "https://cloud.uipath.com/draft/2024-12/entry-point"

pack_options_spec_url = "https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md#4-packoptions"


class PackageMetadataConflictError(Exception):
"""Raised when project files would be packaged over generated package metadata."""


def get_project_version(directory):
toml_path = os.path.join(directory, PYTHON_CONFIGURATION_FILE)
Expand Down Expand Up @@ -205,7 +212,45 @@
)


def archive_path_for(file: FileInfo) -> str:
"""Return the path a project file is packaged under."""
return f"content/{file.relative_path}"
Comment thread
radu-mocanu marked this conversation as resolved.


def raise_on_metadata_conflicts(
metadata_files: dict[str, str], files: list[FileInfo]
) -> None:
"""Reject project files that would be written over generated package metadata.

The zip format allows several entries to share a name, so a project file
packaged at the same archive path as a generated metadata file yields a
package with duplicate entries that fails at extraction time.

Args:
metadata_files: Archive path -> content of the generated metadata files
files: Project files that would be packaged

Raises:
PackageMetadataConflictError: If any project file collides with metadata
"""
reserved = {path.casefold() for path in metadata_files}
conflicts = sorted(
file.relative_path
for file in files
if archive_path_for(file).casefold() in reserved
)
if not conflicts:
return

conflict_list = "\n".join(f" - {path}" for path in conflicts)
raise PackageMetadataConflictError(
f"These project files clash with generated package metadata:\n{conflict_list}\n"
"Delete, rename, or exclude them via packOptions.filesExcluded: "
f"{pack_options_spec_url}"
)


def pack_fn(

Check failure on line 253 in packages/uipath/src/uipath/_cli/cli_pack.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ_W7aGFS9ptKISMskYT&open=AZ_W7aGFS9ptKISMskYT&pullRequest=1848
project_name,
description,
version,
Expand Down Expand Up @@ -239,6 +284,7 @@
)

# try to read bindings from bindings.json
bindings_data: Bindings | None = None
bindings_path = os.path.join(directory, str(UiPathConfig.bindings_file_path))
if os.path.exists(bindings_path):
with open(bindings_path, "r") as f:
Expand All @@ -257,57 +303,59 @@
)
package_descriptor_content = generate_package_descriptor_content(entrypoints)

metadata_files = {
f"./package/services/metadata/core-properties/{psmdcp_file_name}": psmdcp_content,
"[Content_Types].xml": content_types_content,
"content/package-descriptor.json": json.dumps(
package_descriptor_content, indent=4
),
"content/operate.json": json.dumps(operate_file, indent=4),
}
if bindings_data:
metadata_files["content/bindings_v2.json"] = json.dumps(
bindings_data.model_dump(by_alias=True), indent=4
)
Comment thread
radu-mocanu marked this conversation as resolved.
metadata_files[f"{project_name}.nuspec"] = nuspec_content
metadata_files["_rels/.rels"] = rels_content

files, skipped_files = files_to_include(
config_data.pack_options,
directory,
include_uv_lock,
directories_to_ignore=[LEGACY_EVAL_FOLDER, EVALS_FOLDER],
)

raise_on_metadata_conflicts(metadata_files, files)

# Create .uipath directory if it doesn't exist
os.makedirs(".uipath", exist_ok=True)

with zipfile.ZipFile(
f".uipath/{project_name}.{version}.nupkg", "w", zipfile.ZIP_DEFLATED
) as z:
# Add metadata files
z.writestr(
f"./package/services/metadata/core-properties/{psmdcp_file_name}",
psmdcp_content,
)
z.writestr("[Content_Types].xml", content_types_content)
z.writestr(
"content/package-descriptor.json",
json.dumps(package_descriptor_content, indent=4),
)
z.writestr("content/operate.json", json.dumps(operate_file, indent=4))
if bindings_data:
z.writestr(
"content/bindings_v2.json",
json.dumps(bindings_data.model_dump(by_alias=True), indent=4),
)
z.writestr(f"{project_name}.nuspec", nuspec_content)
z.writestr("_rels/.rels", rels_content)

files, skipped_files = files_to_include(
config_data.pack_options,
directory,
include_uv_lock,
directories_to_ignore=[LEGACY_EVAL_FOLDER, EVALS_FOLDER],
)
for archive_path, content in metadata_files.items():
z.writestr(archive_path, content)

for file in files:
archive_path = archive_path_for(file)
if file.is_binary:
# Read binary files in binary mode
with open(file.file_path, "rb") as f:
z.writestr(f"content/{file.relative_path}", f.read())
z.writestr(archive_path, f.read())
else:
try:
# Try UTF-8 first
with open(file.file_path, "r", encoding="utf-8") as f:
z.writestr(f"content/{file.relative_path}", f.read())
z.writestr(archive_path, f.read())
except UnicodeDecodeError:
# If UTF-8 fails, try with utf-8-sig (for files with BOM)
try:
with open(file.file_path, "r", encoding="utf-8-sig") as f:
z.writestr(f"content/{file.relative_path}", f.read())
z.writestr(archive_path, f.read())
except UnicodeDecodeError:
# If that also fails, try with latin-1 as a fallback
with open(file.file_path, "r", encoding="latin-1") as f:
z.writestr(f"content/{file.relative_path}", f.read())
z.writestr(archive_path, f.read())


def display_project_info(config):
Expand Down Expand Up @@ -362,6 +410,8 @@
display_project_info(config)
console.success("Project successfully packaged.")

except PackageMetadataConflictError as e:
console.error(str(e))
except Exception as e:
console.error(
f"Failed to create package {config['project_name']}.{version or config['version']}: {str(e)}"
Expand Down
175 changes: 175 additions & 0 deletions packages/uipath/tests/cli/test_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import zipfile
from unittest.mock import patch

import pytest
from click.testing import CliRunner
from utils.project_details import ProjectDetails

Expand Down Expand Up @@ -1460,3 +1461,177 @@ def test_pack_warns_mixed_entrypoint_types(
assert (
"We recommend using a single type for all entrypoints" in result.output
)


class TestPackMetadataConflicts:
"""Test that project files cannot shadow generated package metadata."""

def _setup_project(self, project_details: ProjectDetails, pack_options=None):
with open("uipath.json", "w") as f:
json.dump(create_uipath_json(pack_options=pack_options), f)
with open("pyproject.toml", "w") as f:
f.write(project_details.to_toml())
with open("main.py", "w") as f:
f.write("def main(input): return input")
create_bindings_file()
create_entry_points_file()

@pytest.mark.parametrize(
"conflicting_file",
["operate.json", "package-descriptor.json", "bindings_v2.json"],
)
def test_pack_fails_when_metadata_file_exists_in_project(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
conflicting_file: str,
) -> None:
"""Test that a project file named like generated metadata blocks packing."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(project_details)
with open(conflicting_file, "w") as f:
json.dump({"stale": True}, f)

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 1
assert "These project files clash with generated package metadata:" in (
result.output
)
assert f"- {conflicting_file}" in result.output
assert "packOptions.filesExcluded" in result.output
assert "specs/uipath.spec.md#4-packoptions" in result.output
assert not os.path.exists(
f".uipath/{project_details.name}.{project_details.version}.nupkg"
)

def test_pack_reports_all_conflicting_files(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that every conflicting file is listed, not just the first one."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(project_details)
for conflicting_file in ("operate.json", "package-descriptor.json"):
with open(conflicting_file, "w") as f:
json.dump({}, f)

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 1
assert "- operate.json" in result.output
assert "- package-descriptor.json" in result.output

def test_pack_conflict_detection_is_case_insensitive(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that a case variant is rejected, since extraction is case-insensitive."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(project_details)
with open("Operate.json", "w") as f:
json.dump({}, f)

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 1
assert "- Operate.json" in result.output

def test_pack_allows_metadata_names_in_subdirectories(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that the same file name below the project root is not a conflict."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(project_details)
os.makedirs("fixtures")
with open(os.path.join("fixtures", "operate.json"), "w") as f:
json.dump({"fixture": True}, f)

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 0
nupkg_path = (
f".uipath/{project_details.name}.{project_details.version}.nupkg"
)
with zipfile.ZipFile(nupkg_path, "r") as z:
names = z.namelist()
assert "content/fixtures/operate.json" in names
assert names.count("content/operate.json") == 1

def test_pack_succeeds_when_conflicting_file_is_excluded(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that packOptions.filesExcluded resolves the conflict."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(
project_details, pack_options={"filesExcluded": ["operate.json"]}
)
with open("operate.json", "w") as f:
json.dump({"stale": True}, f)

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 0
nupkg_path = (
f".uipath/{project_details.name}.{project_details.version}.nupkg"
)
with zipfile.ZipFile(nupkg_path, "r") as z:
names = z.namelist()
assert names.count("content/operate.json") == 1
assert json.loads(z.read("content/operate.json")) != {"stale": True}

def test_nupkg_has_no_duplicate_entries(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that a packed project never contains duplicate archive entries."""
with runner.isolated_filesystem(temp_dir=temp_dir):
self._setup_project(project_details)

result = runner.invoke(cli, ["pack", "./"], env={})
assert result.exit_code == 0

nupkg_path = (
f".uipath/{project_details.name}.{project_details.version}.nupkg"
)
with zipfile.ZipFile(nupkg_path, "r") as z:
names = z.namelist()
assert len(names) == len(set(names))

def test_pack_without_bindings_file(
self,
runner: CliRunner,
temp_dir: str,
project_details: ProjectDetails,
) -> None:
"""Test that packing works when bindings.json is absent."""
with runner.isolated_filesystem(temp_dir=temp_dir):
with open("uipath.json", "w") as f:
json.dump(create_uipath_json(), f)
with open("pyproject.toml", "w") as f:
f.write(project_details.to_toml())
with open("main.py", "w") as f:
f.write("def main(input): return input")
create_entry_points_file()

result = runner.invoke(cli, ["pack", "./"], env={})

assert result.exit_code == 0, result.output
nupkg_path = (
f".uipath/{project_details.name}.{project_details.version}.nupkg"
)
with zipfile.ZipFile(nupkg_path, "r") as z:
assert "content/bindings_v2.json" not in z.namelist()
2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading