diff --git a/pyproject.toml b/pyproject.toml index 5bb860c5d..0c08d91f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.1.160" +version = "2.1.161" 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.10" diff --git a/src/uipath/_cli/_push/sw_file_handler.py b/src/uipath/_cli/_push/sw_file_handler.py index 01509a0fd..f1cee42f2 100644 --- a/src/uipath/_cli/_push/sw_file_handler.py +++ b/src/uipath/_cli/_push/sw_file_handler.py @@ -14,9 +14,7 @@ from .._utils._console import ConsoleLogger from .._utils._constants import ( AGENT_INITIAL_CODE_VERSION, - AGENT_STORAGE_VERSION, - AGENT_TARGET_RUNTIME, - AGENT_VERSION, + SCHEMA_VERSION, ) from .._utils._project_files import ( # type: ignore FileInfo, @@ -263,7 +261,7 @@ async def _process_file_uploads( deleted_files = self._collect_deleted_files( remote_files, processed_source_files, - files_to_ignore=["agent.json"], + files_to_ignore=["studio_metadata.json"], directories_to_ignore=[ name for name, condition in [ @@ -289,16 +287,12 @@ async def _process_file_uploads( ) ) - # Load uipath.json configuration - with open(os.path.join(self.directory, "uipath.json"), "r") as f: - uipath_config = json.load(f) - - # Prepare agent.json migration (may download existing file to increment version) - agent_update = await self._prepare_agent_json_migration( - structural_migration, remote_files, uipath_config + # Prepare metadata file + update_metadata_event = await self._prepare_metadata_file( + structural_migration, remote_files ) - if agent_update: - updates.append(agent_update) + if update_metadata_event: + updates.append(update_metadata_event) # Perform the structural migration (uploads/updates/deletes all files) await self._studio_client.perform_structural_migration_async( @@ -411,24 +405,21 @@ def _is_folder_empty(self, folder: ProjectFolder) -> bool: return True - async def _prepare_agent_json_migration( + async def _prepare_metadata_file( self, structural_migration: StructuralMigration, remote_files: Dict[str, ProjectFile], - uipath_config: Dict[str, Any], ) -> Optional[UpdateEvent]: - """Prepare agent.json to be included in the same structural migration. + """Prepare .uipath/studio_metadata.json file. This method: - 1. Extracts author from JWT token or pyproject.toml - 2. Downloads existing agent.json if it exists to increment code version - 3. Builds complete agent.json structure - 4. Adds to structural migration as modified or added resource + 1. Checks if file exists locally, initializes with defaults if not + 2. Extracts author from JWT token or pyproject.toml + 3. Downloads existing studio_metadata.json from remote if it exists to increment code version Args: structural_migration: The structural migration to add resources to remote_files: Dictionary of remote files - uipath_config: Configuration from uipath.json Returns: FileOperationUpdate describing the operation, or None if error occurred @@ -451,74 +442,69 @@ def get_author_from_token_or_toml() -> str: author = get_author_from_token_or_toml() - # Initialize agent.json structure with metadata - agent_json = { - "version": AGENT_VERSION, - "metadata": { - "storageVersion": AGENT_STORAGE_VERSION, - "targetRuntime": AGENT_TARGET_RUNTIME, - "isConversational": False, + local_metadata_file = os.path.join( + self.directory, str(UiPathConfig.studio_metadata_file_path) + ) + + if not os.path.exists(local_metadata_file): + metadata = { + "schemaVersion": SCHEMA_VERSION, + "lastPushDate": datetime.now(timezone.utc).isoformat(), + "lastPushAuthor": author, "codeVersion": AGENT_INITIAL_CODE_VERSION, - "author": author, - "pushDate": datetime.now(timezone.utc).isoformat(), - }, - "bindings": uipath_config.get( - "bindings", {"version": "2.0", "resources": []} - ), - # TODO: remove this after validation check gets removed on SW side - "entryPoints": [{}], - } - - existing = remote_files.get("agent.json") + } + os.makedirs(os.path.dirname(local_metadata_file), exist_ok=True) + with open(local_metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + else: + with open(local_metadata_file, "r") as f: + metadata = json.load(f) + + existing = remote_files.get(".uipath/studio_metadata.json") if existing: - # Agent.json exists - download and increment version try: - existing_agent_json = ( + existing_metadata = ( await self._studio_client.download_project_file_async(existing) ).json() - version_parts = existing_agent_json["metadata"]["codeVersion"].split( - "." - ) + version_parts = existing_metadata["codeVersion"].split(".") if len(version_parts) >= 3: # Increment patch version (0.1.0 -> 0.1.1) version_parts[-1] = str(int(version_parts[-1]) + 1) - agent_json["metadata"]["codeVersion"] = ".".join(version_parts) + metadata["codeVersion"] = ".".join(version_parts) else: # Invalid version format, use default with patch = 1 - agent_json["metadata"]["codeVersion"] = ( - AGENT_INITIAL_CODE_VERSION[:-1] + "1" - ) + metadata["codeVersion"] = AGENT_INITIAL_CODE_VERSION[:-1] + "1" except Exception: logger.info( - "Could not parse existing 'agent.json' file, using default version" + "Could not parse existing metadata file, using default version" ) + with open(local_metadata_file, "w") as f: + f.write(json.dumps(metadata)) + structural_migration.modified_resources.append( ModifiedResource( id=existing.id, - content_string=json.dumps(agent_json), + content_string=json.dumps(metadata), ) ) return UpdateEvent( - file_path="agent.json", + file_path=".uipath/studio_metadata.json", status="updating", - message="Updating 'agent.json'", + message="Updating '.uipath/studio_metadata.json'", ) else: - # Agent.json doesn't exist - create new one - logger.info( - "'agent.json' file does not exist in Studio Web project, initializing using default version" - ) structural_migration.added_resources.append( AddedResource( - file_name="agent.json", - content_string=json.dumps(agent_json), + file_name="studio_metadata.json", + content_string=json.dumps(metadata), + parent_path=".uipath", ) ) return UpdateEvent( - file_path="agent.json", + file_path=".uipath/studio_metadata.json", status="uploading", - message="Uploading 'agent.json'", + message="Uploading '.uipath/studio_metadata.json'", ) async def upload_source_files( diff --git a/src/uipath/_cli/_utils/_constants.py b/src/uipath/_cli/_utils/_constants.py index 688441a9a..64170a78f 100644 --- a/src/uipath/_cli/_utils/_constants.py +++ b/src/uipath/_cli/_utils/_constants.py @@ -1,8 +1,7 @@ BINDINGS_VERSION = "2.2" # Agent.json constants -AGENT_VERSION = "1.0.0" -AGENT_STORAGE_VERSION = "1.0.0" +SCHEMA_VERSION = "1.0.0" AGENT_INITIAL_CODE_VERSION = "1.0.0" AGENT_TARGET_RUNTIME = "python" diff --git a/src/uipath/_config.py b/src/uipath/_config.py index 573ac3fd3..f440d4a17 100644 --- a/src/uipath/_config.py +++ b/src/uipath/_config.py @@ -72,5 +72,11 @@ def entry_points_file_path(self) -> Path: return Path(ENTRY_POINTS_FILE) + @property + def studio_metadata_file_path(self) -> Path: + from uipath._utils.constants import STUDIO_METADATA_FILE + + return Path(".uipath", STUDIO_METADATA_FILE) + UiPathConfig = ConfigurationManager() diff --git a/src/uipath/_utils/constants.py b/src/uipath/_utils/constants.py index 26459de1b..e65b9102a 100644 --- a/src/uipath/_utils/constants.py +++ b/src/uipath/_utils/constants.py @@ -50,6 +50,7 @@ UIPATH_CONFIG_FILE = "uipath.json" UIPATH_BINDINGS_FILE = "bindings.json" ENTRY_POINTS_FILE = "entry-points.json" +STUDIO_METADATA_FILE = "studio_metadata.json" # Folder names diff --git a/tests/cli/test_push.py b/tests/cli/test_push.py index 5add67c85..0711b4135 100644 --- a/tests/cli/test_push.py +++ b/tests/cli/test_push.py @@ -14,23 +14,21 @@ from uipath._cli import cli -def extract_agent_json_from_modified_resources( - request: Request, *, agent_file_id: str | None = None +def extract_metadata_json_from_modified_resources( + request: Request, *, metadata_file_id: str | None = None ) -> dict[str, Any]: - """Extract agent.json content from ModifiedResources in StructuralMigration payload.""" + """Extract studio_metadata.json content from ModifiedResources in StructuralMigration payload.""" match = re.search( rb"boundary=([-._0-9A-Za-z]+)", request.headers.get("Content-Type", "").encode() ) if match is None: - # Fallback to body sniffing like older helper match = re.search(rb"--([-._0-9A-Za-z]+)", request.content) assert match is not None, "Could not detect multipart boundary" boundary = match.group(1) parts = request.content.split(b"--" + boundary) - # Require agent_file_id and search only ModifiedResources - assert agent_file_id is not None, ( - "agent_file_id is required to extract agent.json from ModifiedResources" + assert metadata_file_id is not None, ( + "metadata_file_id is required to extract studio_metadata.json from ModifiedResources" ) target_index: str | None = None for part in parts: @@ -42,7 +40,7 @@ def extract_agent_json_from_modified_resources( body = part.split(b"\r\n\r\n", 1) if len(body) == 2: value = body[1].strip().strip(b"\r\n") - if value.decode(errors="ignore") == agent_file_id: + if value.decode(errors="ignore") == metadata_file_id: m = re.search(rb"ModifiedResources\[(\d+)\]\.Id", part) if m: target_index = m.group(1).decode() @@ -58,12 +56,12 @@ def extract_agent_json_from_modified_resources( return json.loads(content_bytes.decode()) raise AssertionError( - "agent.json content not found in ModifiedResources of StructuralMigration payload" + "studio_metadata.json content not found in ModifiedResources of StructuralMigration payload" ) -def extract_agent_json_from_added_resources(request: Request) -> dict[str, Any]: - """Extract agent.json content from AddedResources in StructuralMigration payload.""" +def extract_metadata_json_from_added_resources(request: Request) -> dict[str, Any]: + """Extract studio_metadata.json content from AddedResources in StructuralMigration payload.""" match = re.search( rb"boundary=([-._0-9A-Za-z]+)", request.headers.get("Content-Type", "").encode() ) @@ -78,13 +76,13 @@ def extract_agent_json_from_added_resources(request: Request) -> dict[str, Any]: b"Content-Disposition: form-data;" in part and b"AddedResources[" in part and b"].Content" in part - and b'filename="agent.json"' in part + and b'filename="studio_metadata.json"' in part ): content_bytes = part.split(b"\r\n\r\n", 1)[1].split(b"\r\n")[0] return json.loads(content_bytes.decode()) raise AssertionError( - "agent.json content not found in AddedResources of StructuralMigration payload" + "studio_metadata.json content not found in AddedResources of StructuralMigration payload" ) @@ -144,11 +142,26 @@ def test_successful_push( base_url = "https://cloud.uipath.com/organization" project_id = "test-project-id" - # Mock the project structure response mock_structure = { "id": "root", "name": "root", - "folders": [], + "folders": [ + { + "id": "uipath-folder", + "name": ".uipath", + "folders": [], + "files": [ + { + "id": "246", + "name": "studio_metadata.json", + "isMain": False, + "fileType": "1", + "isEntryPoint": False, + "ignoredFromPublish": False, + }, + ], + }, + ], "files": [ { "id": "123", @@ -174,14 +187,6 @@ def test_successful_push( "isEntryPoint": False, "ignoredFromPublish": False, }, - { - "id": "246", - "name": "agent.json", - "isMain": False, - "fileType": "1", - "isEntryPoint": False, - "ignoredFromPublish": False, - }, { "id": "898", "name": "entry-points.json", @@ -226,12 +231,11 @@ def test_successful_push( text='{"version": "old"}', ) - # Mock agent.json download httpx_mock.add_response( method="GET", url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/File/246", status_code=200, - json={"metadata": {"codeVersion": "0.1.0"}}, + json={"codeVersion": "0.1.0", "schemaVersion": "1.0"}, ) # Mock entry-points.json download @@ -276,33 +280,28 @@ def test_successful_push( configure_env_vars(mock_env_vars) os.environ["UIPATH_PROJECT_ID"] = project_id - # Run push result = runner.invoke(cli, ["push", "./"]) assert result.exit_code == 0 assert "Updating 'main.py'" in result.output assert "Updating 'pyproject.toml'" in result.output assert "Updating 'uipath.json'" in result.output assert "Uploading 'uv.lock'" in result.output - assert "Updating 'agent.json'" in result.output + assert "Updating '.uipath/studio_metadata.json'" in result.output assert "Updating 'entry-points.json'" in result.output - # check incremented code version via StructuralMigration payload structural_migration_request = httpx_mock.get_request( method="POST", url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/StructuralMigration", ) assert structural_migration_request is not None - agent_json_content = extract_agent_json_from_modified_resources( - structural_migration_request, agent_file_id="246" + metadata_json_content = extract_metadata_json_from_modified_resources( + structural_migration_request, metadata_file_id="246" ) - # Validate `metadata["codeVersion"]` expected_code_version = "0.1.1" - actual_code_version = agent_json_content.get("metadata", {}).get( - "codeVersion" - ) + actual_code_version = metadata_json_content.get("codeVersion") assert actual_code_version == expected_code_version, ( - f"Unexpected codeVersion in metadata. Expected: {expected_code_version}, Got: {actual_code_version}" + f"Unexpected codeVersion. Expected: {expected_code_version}, Got: {actual_code_version}" ) def test_successful_push_new_project( @@ -375,35 +374,29 @@ def test_successful_push_new_project( configure_env_vars(mock_env_vars) os.environ["UIPATH_PROJECT_ID"] = project_id - # Run push result = runner.invoke(cli, ["push", "./"]) assert result.exit_code == 0 assert "Uploading 'main.py'" in result.output assert "Uploading 'pyproject.toml'" in result.output assert "Uploading 'uipath.json'" in result.output assert "Uploading 'uv.lock'" in result.output - assert "Uploading 'agent.json'" in result.output + assert "Uploading '.uipath/studio_metadata.json'" in result.output assert "Uploading 'entry-points.json'" in result.output - # check expected agent.json fields structural_migration_request = httpx_mock.get_request( method="POST", url=f"{base_url}/studio_/backend/api/Project/{project_id}/FileOperations/StructuralMigration", ) assert structural_migration_request is not None - agent_json_content = extract_agent_json_from_added_resources( + metadata_json_content = extract_metadata_json_from_added_resources( structural_migration_request ) expected_code_version = "1.0.0" - actual_code_version = agent_json_content.get("metadata", {}).get( - "codeVersion" - ) + actual_code_version = metadata_json_content.get("codeVersion") assert actual_code_version == expected_code_version, ( - f"Unexpected codeVersion in metadata. Expected: {expected_code_version}, Got: {actual_code_version}" + f"Unexpected codeVersion. Expected: {expected_code_version}, Got: {actual_code_version}" ) - assert "targetRuntime" in agent_json_content["metadata"] - assert agent_json_content["metadata"]["targetRuntime"] == "python" def test_push_with_api_error( self, diff --git a/uv.lock b/uv.lock index 22d7c9d4c..a17a0658a 100644 --- a/uv.lock +++ b/uv.lock @@ -3059,7 +3059,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.1.159" +version = "2.1.161" source = { editable = "." } dependencies = [ { name = "azure-monitor-opentelemetry" },