From 4a96536a12dc23f5ef47ed574a87b1797d9476e3 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 6 Aug 2026 14:51:31 +0300 Subject: [PATCH] fix(cli): reject project files clashing with generated package metadata --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_pack.py | 108 ++++++++---- packages/uipath/tests/cli/test_pack.py | 175 ++++++++++++++++++++ packages/uipath/uv.lock | 2 +- 4 files changed, 256 insertions(+), 31 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 675a9d735..599b65f33 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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" diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 19a9bad69..510eeadff 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -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, @@ -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) @@ -205,6 +212,44 @@ def is_venv_dir(d): ) +def archive_path_for(file: FileInfo) -> str: + """Return the path a project file is packaged under.""" + return f"content/{file.relative_path}" + + +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( project_name, description, @@ -239,6 +284,7 @@ def pack_fn( ) # 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: @@ -257,57 +303,59 @@ def pack_fn( ) 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 + ) + 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): @@ -362,6 +410,8 @@ def pack(root, nolock): 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)}" diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index 96d45d07e..cfb1f1a0f 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -5,6 +5,7 @@ import zipfile from unittest.mock import patch +import pytest from click.testing import CliRunner from utils.project_details import ProjectDetails @@ -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() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d94a3297d..c905ec7c3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.0" +version = "2.14.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" },