diff --git a/docs/core/attachments.md b/docs/core/attachments.md index 8c5819a9..2c2b9203 100644 --- a/docs/core/attachments.md +++ b/docs/core/attachments.md @@ -1,86 +1 @@ -# Attachments Service - -The `AttachmentsService` provides methods to upload, download, and delete attachments in UiPath Orchestrator. Attachments are files that can be associated with jobs, processes, or other entities, and are managed via the Orchestrator API. - -> **Reference:** [UiPath Orchestrator Attachments API](https://docs.uipath.com/orchestrator/reference/api-attachments) - -## Features -- Upload files or in-memory content as attachments -- Download attachments to local files -- Delete attachments -- Both synchronous and asynchronous methods - -## Usage - -### Instantiating the Service - -The `AttachmentsService` is available as a property on the main `UiPath` client: - -```python -from uipath import UiPath - -client = UiPath() -attachments = client.attachments -``` - -### Uploading an Attachment - -You can upload a file from disk or from memory: - -```python -# Upload from file -attachment_key = client.attachments.upload( - name="document.pdf", - source_path="/path/to/document.pdf", -) - -# Upload from memory -attachment_key = client.attachments.upload( - name="notes.txt", - content="Some text content", -) -``` - -#### Async Example -```python -attachment_key = await client.attachments.upload_async( - name="notes.txt", - content="Some text content", -) -``` - -### Downloading an Attachment - -```python -attachment_name = client.attachments.download( - key=attachment_key, - destination_path="/path/to/save/document.pdf", -) -``` - -#### Async Example -```python -attachment_name = await client.attachments.download_async( - key=attachment_key, - destination_path="/path/to/save/document.pdf", -) -``` - -### Deleting an Attachment - -```python -client.attachments.delete(key=attachment_key) -``` - -#### Async Example -```python -await client.attachments.delete_async(key=attachment_key) -``` - -## Error Handling - -All methods raise exceptions on failure. See the SDK error handling documentation for details. - -## See Also -- [UiPath Orchestrator Attachments API](https://docs.uipath.com/orchestrator/reference/api-attachments) -- [Jobs Service](./jobs.md) for listing attachments associated with jobs. \ No newline at end of file +::: uipath._services.attachments_service \ No newline at end of file diff --git a/src/uipath/_services/attachments_service.py b/src/uipath/_services/attachments_service.py index 2300c01f..d00e6927 100644 --- a/src/uipath/_services/attachments_service.py +++ b/src/uipath/_services/attachments_service.py @@ -1,12 +1,17 @@ +import os +import shutil +import tempfile import uuid +from pathlib import Path from typing import Any, Dict, Optional, Union, overload -from httpx import request +import httpx from .._config import Config from .._execution_context import ExecutionContext from .._folder_context import FolderContext from .._utils import Endpoint, RequestSpec, header_folder +from .._utils.constants import TEMP_ATTACHMENTS_FOLDER from ..tracing._traced import traced from ._base_service import BaseService @@ -35,6 +40,7 @@ class AttachmentsService(FolderContext, BaseService): def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__(config=config, execution_context=execution_context) + self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) @traced(name="attachments_download", run_type="uipath") def download( @@ -48,6 +54,12 @@ def download( """Download an attachment. This method downloads an attachment from UiPath to a local file. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. Args: key (uuid.UUID): The key of the attachment to download. @@ -59,7 +71,7 @@ def download( str: The name of the downloaded attachment. Raises: - Exception: If the download fails. + Exception: If the download fails and no local file is found. Examples: ```python @@ -74,42 +86,75 @@ def download( print(f"Downloaded attachment: {attachment_name}") ``` """ - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - # Get the attachment name - attachment_name = result["Name"] - - download_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, + try: + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, ) - } - with open(destination_path, "wb") as file: - if result["BlobFileAccess"]["RequiresAuth"]: - file_content = self.request( - "GET", download_uri, headers=headers - ).content - else: - file_content = request("GET", download_uri, headers=headers).content - file.write(file_content) + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + # Get the attachment name + attachment_name = result["Name"] + + download_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } - return attachment_name + with open(destination_path, "wb") as file: + if result["BlobFileAccess"]["RequiresAuth"]: + response = self.request( + "GET", download_uri, headers=headers, stream=True + ) + for chunk in response.iter_bytes(chunk_size=8192): + file.write(chunk) + else: + with httpx.Client() as client: + with client.stream( + "GET", download_uri, headers=headers + ) as response: + for chunk in response.iter_bytes(chunk_size=8192): + file.write(chunk) + + return attachment_name + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Get the full filename + local_file = matching_files[0] + + # Extract the original name from the filename (part after UUID_) + file_name = os.path.basename(local_file) + original_name = file_name[len(f"{key}_") :] + + # Copy the file to the destination + shutil.copy2(local_file, destination_path) + + return original_name + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e @traced(name="attachments_download", run_type="uipath") async def download_async( @@ -123,6 +168,12 @@ async def download_async( """Download an attachment asynchronously. This method asynchronously downloads an attachment from UiPath to a local file. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. Args: key (uuid.UUID): The key of the attachment to download. @@ -134,7 +185,7 @@ async def download_async( str: The name of the downloaded attachment. Raises: - Exception: If the download fails. + Exception: If the download fails and no local file is found. Examples: ```python @@ -151,44 +202,77 @@ async def main(): print(f"Downloaded attachment: {attachment_name}") ``` """ - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - # Get the attachment name - attachment_name = result["Name"] - - download_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, + try: + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, ) - } - with open(destination_path, "wb") as file: - if result["BlobFileAccess"]["RequiresAuth"]: - response = await self.request_async( - "GET", download_uri, headers=headers + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, ) - file.write(response.content) - else: - file.write(request("GET", download_uri, headers=headers).content) + ).json() + + # Get the attachment name + attachment_name = result["Name"] + + download_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } - return attachment_name + with open(destination_path, "wb") as file: + if result["BlobFileAccess"]["RequiresAuth"]: + response = await self.request_async( + "GET", download_uri, headers=headers, stream=True + ) + async for chunk in response.aiter_bytes(chunk_size=8192): + file.write(chunk) + else: + async with httpx.AsyncClient() as client: + async with client.stream( + "GET", download_uri, headers=headers + ) as response: + async for chunk in response.aiter_bytes(chunk_size=8192): + file.write(chunk) + + return attachment_name + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Get the full filename + local_file = matching_files[0] + + # Extract the original name from the filename (part after UUID_) + file_name = os.path.basename(local_file) + original_name = file_name[len(f"{key}_") :] + + # Copy the file to the destination + shutil.copy2(local_file, destination_path) + + return original_name + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e @overload def upload( @@ -305,7 +389,8 @@ def upload( "PUT", upload_uri, headers=headers, files={"file": file} ) else: - request("PUT", upload_uri, headers=headers, files={"file": file}) + with httpx.Client() as client: + client.put(upload_uri, headers=headers, files={"file": file}) else: # Upload from memory # Convert string to bytes if needed @@ -315,7 +400,8 @@ def upload( if result["BlobFileAccess"]["RequiresAuth"]: self.request("PUT", upload_uri, headers=headers, content=content) else: - request("PUT", upload_uri, headers=headers, content=content) + with httpx.Client() as client: + client.put(upload_uri, headers=headers, content=content) return attachment_key @@ -438,7 +524,8 @@ async def main(): "PUT", upload_uri, headers=headers, files={"file": file} ) else: - request("PUT", upload_uri, headers=headers, files={"file": file}) + with httpx.Client() as client: + client.put(upload_uri, headers=headers, files={"file": file}) else: # Upload from memory # Convert string to bytes if needed @@ -450,7 +537,8 @@ async def main(): "PUT", upload_uri, headers=headers, content=content ) else: - request("PUT", upload_uri, headers=headers, content=content) + with httpx.Client() as client: + client.put(upload_uri, headers=headers, content=content) return attachment_key @@ -465,6 +553,12 @@ def delete( """Delete an attachment. This method deletes an attachment from UiPath. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. Args: key (uuid.UUID): The key of the attachment to delete. @@ -472,7 +566,7 @@ def delete( folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. Raises: - Exception: If the deletion fails. + Exception: If the deletion fails and no local file is found. Examples: ```python @@ -486,17 +580,37 @@ def delete( print("Attachment deleted successfully") ``` """ - spec = self._delete_attachment_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) + try: + spec = self._delete_attachment_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) - self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Delete all matching files + for file_path in matching_files: + os.remove(file_path) + return + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e @traced(name="attachments_delete", run_type="uipath") async def delete_async( @@ -509,6 +623,12 @@ async def delete_async( """Delete an attachment asynchronously. This method asynchronously deletes an attachment from UiPath. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. Args: key (uuid.UUID): The key of the attachment to delete. @@ -516,7 +636,7 @@ async def delete_async( folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. Raises: - Exception: If the deletion fails. + Exception: If the deletion fails and no local file is found. Examples: ```python @@ -532,17 +652,37 @@ async def main(): print("Attachment deleted successfully") ``` """ - spec = self._delete_attachment_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) + try: + spec = self._delete_attachment_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) - await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Delete all matching files + for file_path in matching_files: + os.remove(file_path) + return + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e @property def custom_headers(self) -> Dict[str, str]: diff --git a/src/uipath/_services/jobs_service.py b/src/uipath/_services/jobs_service.py index 19f5af86..d3bb8b93 100644 --- a/src/uipath/_services/jobs_service.py +++ b/src/uipath/_services/jobs_service.py @@ -1,15 +1,20 @@ import json +import os +import shutil +import tempfile import uuid -from typing import Any, Dict, List, Optional, overload +from pathlib import Path +from typing import Any, Dict, List, Optional, Union, cast, overload from .._config import Config from .._execution_context import ExecutionContext from .._folder_context import FolderContext from .._utils import Endpoint, RequestSpec, header_folder -from ..models import Attachment +from .._utils.constants import TEMP_ATTACHMENTS_FOLDER from ..models.job import Job from ..tracing._traced import traced from ._base_service import BaseService +from .attachments_service import AttachmentsService class JobsService(FolderContext, BaseService): @@ -22,6 +27,10 @@ class JobsService(FolderContext, BaseService): def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__(config=config, execution_context=execution_context) + self._attachments_service = AttachmentsService(config, execution_context) + # Define the temp directory path for local attachments + self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) + os.makedirs(self._temp_dir, exist_ok=True) @overload def resume(self, *, inbox_id: str, payload: Any) -> None: ... @@ -212,7 +221,6 @@ async def _retrieve_inbox_id_async( def _extract_first_inbox_id(self, response: Any) -> str: if len(response["value"]) > 0: - # FIXME: is this correct? return response["value"][0]["ItemKey"] else: raise Exception("No inbox found") @@ -275,7 +283,7 @@ def list_attachments( job_key: uuid.UUID, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - ) -> List[Attachment]: + ) -> List[str]: """List attachments associated with a specific job. This method retrieves all attachments linked to a job by its key. @@ -286,23 +294,10 @@ def list_attachments( folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. Returns: - List[Attachment]: A list of attachment objects associated with the job. + List[str]: A list of attachment IDs associated with the job. Raises: Exception: If the retrieval fails. - - Examples: - ```python - from uipath import UiPath - - client = UiPath() - - attachments = client.jobs.list_attachments( - job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - for attachment in attachments: - print(f"Attachment: {attachment.Name}, Key: {attachment.Key}") - ``` """ spec = self._list_job_attachments_spec( job_key=job_key, @@ -317,7 +312,7 @@ def list_attachments( headers=spec.headers, ).json() - return [Attachment.model_validate(item) for item in response] + return [item.get("attachmentId") for item in response] @traced(name="jobs_list_attachments", run_type="uipath") async def list_attachments_async( @@ -326,7 +321,7 @@ async def list_attachments_async( job_key: uuid.UUID, folder_key: Optional[str] = None, folder_path: Optional[str] = None, - ) -> List[Attachment]: + ) -> List[str]: """List attachments associated with a specific job asynchronously. This method asynchronously retrieves all attachments linked to a job by its key. @@ -337,7 +332,7 @@ async def list_attachments_async( folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. Returns: - List[Attachment]: A list of attachment objects associated with the job. + List[str]: A list of attachment IDs associated with the job. Raises: Exception: If the retrieval fails. @@ -353,8 +348,8 @@ async def main(): attachments = await client.jobs.list_attachments_async( job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") ) - for attachment in attachments: - print(f"Attachment: {attachment.Name}, Key: {attachment.Key}") + for attachment_id in attachments: + print(f"Attachment ID: {attachment_id}") ``` """ spec = self._list_job_attachments_spec( @@ -372,7 +367,7 @@ async def main(): ) ).json() - return [Attachment.model_validate(item) for item in response] + return [item.get("attachmentId") for item in response] @traced(name="jobs_link_attachment", run_type="uipath") def link_attachment( @@ -397,20 +392,6 @@ def link_attachment( Raises: Exception: If the link operation fails. - - Examples: - ```python - from uipath import UiPath - - client = UiPath() - - client.jobs.link_attachment( - attachment_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), - job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174001"), - category="Result" - ) - print("Attachment linked to job successfully") - ``` """ spec = self._link_job_attachment_spec( attachment_key=attachment_key, @@ -419,8 +400,7 @@ def link_attachment( folder_key=folder_key, folder_path=folder_path, ) - - return self.request( + self.request( spec.method, url=spec.endpoint, headers=spec.headers, @@ -450,22 +430,6 @@ async def link_attachment_async( Raises: Exception: If the link operation fails. - - Examples: - ```python - import asyncio - from uipath import UiPath - - client = UiPath() - - async def main(): - await client.jobs.link_attachment_async( - attachment_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), - job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174001"), - category="Result" - ) - print("Attachment linked to job successfully") - ``` """ spec = self._link_job_attachment_spec( attachment_key=attachment_key, @@ -474,8 +438,7 @@ async def main(): folder_key=folder_key, folder_path=folder_path, ) - - return await self.request_async( + await self.request_async( spec.method, url=spec.endpoint, headers=spec.headers, @@ -519,3 +482,275 @@ def _link_job_attachment_spec( **header_folder(folder_key, folder_path), }, ) + + @traced(name="jobs_create_attachment", run_type="uipath") + def create_attachment( + self, + *, + name: str, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[Union[str, Path]] = None, + job_key: Optional[Union[str, uuid.UUID]] = None, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> uuid.UUID: + """Create and upload an attachment, optionally linking it to a job. + + This method handles creating an attachment from a file or memory data. + If a job key is provided or available in the execution context, the attachment + will be created in UiPath and linked to the job. If no job is available, + the file will be saved to a temporary storage folder. + + Note: + The local storage functionality (when no job is available) is intended for + local development and debugging purposes only. + + Args: + name (str): The name of the attachment file. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + source_path (Optional[Union[str, Path]]): The local path of the file to upload. + job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of the job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The unique identifier for the created attachment, regardless of whether it was + uploaded to UiPath or stored locally. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + from uipath import UiPath + + client = UiPath() + + # Create attachment from file and link to job + attachment_id = client.jobs.create_attachment( + name="document.pdf", + source_path="path/to/local/document.pdf", + job_key="38073051" + ) + print(f"Created and linked attachment: {attachment_id}") + + # Create attachment from memory content (no job available - saves to temp storage) + attachment_id = client.jobs.create_attachment( + name="report.txt", + content="This is a text report" + ) + print(f"Created attachment: {attachment_id}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + # Get job key from context if not explicitly provided + context_job_key = None + if job_key is None and hasattr(self._execution_context, "job_key"): + context_job_key = self._execution_context.job_key + + # Check if a job is available + if job_key is not None or context_job_key is not None: + # Job is available - create attachment in UiPath and link to job + actual_job_key = job_key if job_key is not None else context_job_key + + # Create the attachment using the attachments service + if content is not None: + attachment_key = self._attachments_service.upload( + name=name, + content=content, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + # source_path must be provided due to validation check above + attachment_key = self._attachments_service.upload( + name=name, + source_path=cast(str, source_path), + folder_key=folder_key, + folder_path=folder_path, + ) + + # Convert to UUID if string + if isinstance(actual_job_key, str): + actual_job_key = uuid.UUID(actual_job_key) + + # Link attachment to job + self.link_attachment( + attachment_key=attachment_key, + job_key=cast(uuid.UUID, actual_job_key), + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + + return attachment_key + else: + # No job available - save to temp folder + # Generate a UUID to use as identifier + attachment_id = uuid.uuid4() + + # Create destination file path + dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") + + # If we have source_path, copy the file + if source_path is not None: + source_path_str = ( + source_path if isinstance(source_path, str) else str(source_path) + ) + shutil.copy2(source_path_str, dest_path) + # If we have content, write it to a file + elif content is not None: + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + with open(dest_path, "wb") as f: + f.write(content) + + # Return only the UUID + return attachment_id + + @traced(name="jobs_create_attachment", run_type="uipath") + async def create_attachment_async( + self, + *, + name: str, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[Union[str, Path]] = None, + job_key: Optional[Union[str, uuid.UUID]] = None, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> uuid.UUID: + """Create and upload an attachment asynchronously, optionally linking it to a job. + + This method asynchronously handles creating an attachment from a file or memory data. + If a job key is provided or available in the execution context, the attachment + will be created in UiPath and linked to the job. If no job is available, + the file will be saved to a temporary storage folder. + + Note: + The local storage functionality (when no job is available) is intended for + local development and debugging purposes only. + + Args: + name (str): The name of the attachment file. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + source_path (Optional[Union[str, Path]]): The local path of the file to upload. + job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of the job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The unique identifier for the created attachment, regardless of whether it was + uploaded to UiPath or stored locally. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + import asyncio + from uipath import UiPath + + client = UiPath() + + async def main(): + # Create attachment from file and link to job + attachment_id = await client.jobs.create_attachment_async( + name="document.pdf", + source_path="path/to/local/document.pdf", + job_key="38073051" + ) + print(f"Created and linked attachment: {attachment_id}") + + # Create attachment from memory content (no job available - saves to temp storage) + attachment_id = await client.jobs.create_attachment_async( + name="report.txt", + content="This is a text report" + ) + print(f"Created attachment: {attachment_id}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + # Get job key from context if not explicitly provided + context_job_key = None + if job_key is None and hasattr(self._execution_context, "job_key"): + context_job_key = self._execution_context.job_key + + # Check if a job is available + if job_key is not None or context_job_key is not None: + # Job is available - create attachment in UiPath and link to job + actual_job_key = job_key if job_key is not None else context_job_key + + # Create the attachment using the attachments service + if content is not None: + attachment_key = await self._attachments_service.upload_async( + name=name, + content=content, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + # source_path must be provided due to validation check above + attachment_key = await self._attachments_service.upload_async( + name=name, + source_path=cast(str, source_path), + folder_key=folder_key, + folder_path=folder_path, + ) + + # Convert to UUID if string + if isinstance(actual_job_key, str): + actual_job_key = uuid.UUID(actual_job_key) + + # Link attachment to job + await self.link_attachment_async( + attachment_key=attachment_key, + job_key=cast(uuid.UUID, actual_job_key), + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + + return attachment_key + else: + # No job available - save to temp folder + # Generate a UUID to use as identifier + attachment_id = uuid.uuid4() + + # Create destination file path + dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") + + # If we have source_path, copy the file + if source_path is not None: + source_path_str = ( + source_path if isinstance(source_path, str) else str(source_path) + ) + shutil.copy2(source_path_str, dest_path) + # If we have content, write it to a file + elif content is not None: + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + with open(dest_path, "wb") as f: + f.write(content) + + # Return only the UUID + return attachment_id diff --git a/src/uipath/_utils/constants.py b/src/uipath/_utils/constants.py index 01561a13..347fd439 100644 --- a/src/uipath/_utils/constants.py +++ b/src/uipath/_utils/constants.py @@ -25,3 +25,6 @@ ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE = ( "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" ) + +# Local storage +TEMP_ATTACHMENTS_FOLDER = "uipath_attachments" diff --git a/tests/sdk/services/test_attachments_service.py b/tests/sdk/services/test_attachments_service.py index 242ae86c..df759005 100644 --- a/tests/sdk/services/test_attachments_service.py +++ b/tests/sdk/services/test_attachments_service.py @@ -1,7 +1,8 @@ import json import os +import shutil import uuid -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, Generator, Tuple import pytest from pytest_httpx import HTTPXMock @@ -9,7 +10,7 @@ from uipath._config import Config from uipath._execution_context import ExecutionContext from uipath._services.attachments_service import AttachmentsService -from uipath._utils.constants import HEADER_USER_AGENT +from uipath._utils.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER if TYPE_CHECKING: from _pytest.monkeypatch import MonkeyPatch @@ -36,19 +37,73 @@ def service( @pytest.fixture -def temp_file(tmp_path: Any) -> str: +def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: """Creates a temporary file for testing file uploads and downloads. Args: tmp_path: PyTest fixture providing a temporary directory. Returns: - str: Path to the temporary test file. + A tuple containing the file content, file name, and file path. """ - file_path = os.path.join(tmp_path, "test_file.txt") + content = "Test content" + name = f"test_file_{uuid.uuid4()}.txt" + path = os.path.join(tmp_path, name) + + with open(path, "w") as f: + f.write(content) + + yield content, name, path + + # Clean up the file after the test + if os.path.exists(path): + os.remove(path) + + +@pytest.fixture +def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: + """Create a temporary directory for attachments and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + The path to the temporary directory. + """ + test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) + os.makedirs(test_temp_dir, exist_ok=True) + + yield test_temp_dir + + # Clean up the directory after the test + if os.path.exists(test_temp_dir): + shutil.rmtree(test_temp_dir) + + +@pytest.fixture +def local_attachment_file( + temp_attachments_dir: str, +) -> Generator[Tuple[uuid.UUID, str, str], None, None]: + """Creates a local attachment file in the temporary attachments directory. + + Args: + temp_attachments_dir: The temporary attachments directory. + + Returns: + A tuple containing the attachment ID, file name, and file content. + """ + attachment_id = uuid.uuid4() + file_name = "test_local_file.txt" + file_content = "Local test content" + + # Create the local file with the format {uuid}_{filename} + file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") with open(file_path, "w") as f: - f.write("Test content") - return file_path + f.write(file_content) + + yield attachment_id, file_name, file_content + + # Cleanup is handled by temp_attachments_dir fixture @pytest.fixture @@ -83,7 +138,7 @@ def test_upload_with_file_path( org: str, tenant: str, version: str, - temp_file: str, + temp_file: Tuple[str, str, str], blob_uri_response: Dict[str, Any], ) -> None: """Test uploading an attachment from a file path. @@ -95,11 +150,11 @@ def test_upload_with_file_path( org: Organization fixture for the API path. tenant: Tenant fixture for the API path. version: Version fixture for the user agent header. - temp_file: Temporary file fixture. + temp_file: Temporary file fixture tuple (content, name, path). blob_uri_response: Mock response fixture for blob operations. """ # Arrange - file_name = os.path.basename(temp_file) + content, file_name, file_path = temp_file # Mock the create attachment endpoint httpx_mock.add_response( @@ -119,7 +174,7 @@ def test_upload_with_file_path( # Act attachment_key = service.upload( name=file_name, - source_path=temp_file, + source_path=file_path, ) # Assert @@ -127,10 +182,12 @@ def test_upload_with_file_path( # Verify the requests requests = httpx_mock.get_requests() + assert requests is not None assert len(requests) == 2 # Check the first request to create the attachment create_request = requests[0] + assert create_request is not None assert create_request.method == "POST" assert ( create_request.url @@ -144,6 +201,7 @@ def test_upload_with_file_path( # Check the second request to upload the content upload_request = requests[1] + assert upload_request is not None assert upload_request.method == "PUT" assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] assert "x-ms-blob-type" in upload_request.headers @@ -200,10 +258,12 @@ def test_upload_with_content( # Verify the requests requests = httpx_mock.get_requests() + assert requests is not None assert len(requests) == 2 # Check the first request to create the attachment create_request = requests[0] + assert create_request is not None assert create_request.method == "POST" assert ( create_request.url @@ -214,6 +274,7 @@ def test_upload_with_content( # Check the second request to upload the content upload_request = requests[1] + assert upload_request is not None assert upload_request.method == "PUT" assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] assert "x-ms-blob-type" in upload_request.headers @@ -249,7 +310,6 @@ async def test_upload_async_with_content( base_url: str, org: str, tenant: str, - version: str, blob_uri_response: Dict[str, Any], ) -> None: """Test asynchronously uploading an attachment with in-memory content. @@ -293,11 +353,14 @@ async def test_upload_async_with_content( # Verify the requests requests = httpx_mock.get_requests() + assert requests is not None assert len(requests) == 2 - + assert requests is not None # Check the first request to create the attachment create_request = requests[0] + assert create_request is not None assert create_request.method == "POST" + assert create_request is not None assert ( create_request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" @@ -363,11 +426,14 @@ def test_download( # Verify the requests requests = httpx_mock.get_requests() + assert requests is not None assert len(requests) == 2 - + assert requests is not None # Check the first request to get the attachment metadata get_request = requests[0] + assert get_request is not None assert get_request.method == "GET" + assert get_request is not None assert ( get_request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" @@ -376,8 +442,9 @@ def test_download( # Check the second request to download the content download_request = requests[1] + assert download_request is not None assert download_request.method == "GET" - assert download_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + assert download_request is not None @pytest.mark.asyncio async def test_download_async( @@ -471,10 +538,9 @@ def test_delete( # Verify the request request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") - + assert request is not None assert request.method == "DELETE" + assert request is not None assert ( request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" @@ -519,10 +585,9 @@ async def test_delete_async( # Verify the request request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") - + assert request is not None assert request.method == "DELETE" + assert request is not None assert ( request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" @@ -531,3 +596,296 @@ async def test_delete_async( assert request.headers[HEADER_USER_AGENT].startswith( f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.delete_async/{version}" ) + + def test_download_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Any, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test downloading an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + tmp_path: Temporary directory fixture. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, file_content = local_attachment_file + destination_path = os.path.join(tmp_path, "downloaded_file.txt") + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + result = service.download( + key=attachment_id, + destination_path=destination_path, + ) + + # Assert + assert result == file_name + assert os.path.exists(destination_path) + + with open(destination_path, "r") as f: + assert f.read() == file_content + + @pytest.mark.asyncio + async def test_download_async_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Any, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test asynchronously downloading an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage, using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + tmp_path: Temporary directory fixture. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, file_content = local_attachment_file + destination_path = os.path.join(tmp_path, "downloaded_file_async.txt") + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + result = await service.download_async( + key=attachment_id, + destination_path=destination_path, + ) + + # Assert + assert result == file_name + assert os.path.exists(destination_path) + + with open(destination_path, "r") as f: + assert f.read() == file_content + + def test_delete_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test deleting an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, _ = local_attachment_file + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Verify the file exists before deletion + expected_path = os.path.join( + temp_attachments_dir, f"{attachment_id}_{file_name}" + ) + assert os.path.exists(expected_path) + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + service.delete(key=attachment_id) + + # Assert - verify the file was deleted + assert not os.path.exists(expected_path) + + @pytest.mark.asyncio + async def test_delete_async_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test asynchronously deleting an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage, using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, _ = local_attachment_file + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Verify the file exists before deletion + expected_path = os.path.join( + temp_attachments_dir, f"{attachment_id}_{file_name}" + ) + assert os.path.exists(expected_path) + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + await service.delete_async(key=attachment_id) + + # Assert - verify the file was deleted + assert not os.path.exists(expected_path) + + def test_delete_not_found_throws_exception( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that deleting a non-existent attachment throws an exception. + + This test verifies that when an attachment is not found in UiPath + and not found locally, an exception is raised. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + """ + # Arrange + attachment_id = uuid.uuid4() + + # Set a non-existent temp dir to ensure no local files are found + service._temp_dir = "non_existent_dir" + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act & Assert + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_id} not found in UiPath or local storage", + ): + service.delete(key=attachment_id) + + @pytest.mark.asyncio + async def test_delete_async_not_found_throws_exception( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that asynchronously deleting a non-existent attachment throws an exception. + + This test verifies that when an attachment is not found in UiPath + and not found locally, an exception is raised when using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + """ + # Arrange + attachment_id = uuid.uuid4() + + # Set a non-existent temp dir to ensure no local files are found + service._temp_dir = "non_existent_dir" + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act & Assert + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_id} not found in UiPath or local storage", + ): + await service.delete_async(key=attachment_id) diff --git a/tests/sdk/services/test_jobs_service.py b/tests/sdk/services/test_jobs_service.py index 7dc4daba..793fa051 100644 --- a/tests/sdk/services/test_jobs_service.py +++ b/tests/sdk/services/test_jobs_service.py @@ -1,16 +1,22 @@ import json +import os +import shutil import uuid +from typing import TYPE_CHECKING, Any, Generator, Tuple import pytest from pytest_httpx import HTTPXMock +from pytest_mock import MockerFixture from uipath._config import Config from uipath._execution_context import ExecutionContext from uipath._services.jobs_service import JobsService -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.models import Attachment +from uipath._utils.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER from uipath.models.job import Job +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + @pytest.fixture def service( @@ -19,7 +25,80 @@ def service( monkeypatch: pytest.MonkeyPatch, ) -> JobsService: monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return JobsService(config=config, execution_context=execution_context) + jobs_service = JobsService(config=config, execution_context=execution_context) + # We'll leave the real AttachmentsService for HTTP tests, + # and mock it in specific tests as needed + return jobs_service + + +@pytest.fixture +def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: + """Create a temporary directory for attachments and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + The path to the temporary directory. + """ + test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) + os.makedirs(test_temp_dir, exist_ok=True) + + yield test_temp_dir + + # Clean up the directory after the test + if os.path.exists(test_temp_dir): + shutil.rmtree(test_temp_dir) + + +@pytest.fixture +def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: + """Create a temporary file and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + A tuple containing the file content, file name, and file path. + """ + content = "Test source file content" + name = f"test_file_{uuid.uuid4()}.txt" + path = os.path.join(tmp_path, name) + + with open(path, "w") as f: + f.write(content) + + yield content, name, path + + # Clean up the file after the test + if os.path.exists(path): + os.remove(path) + + +@pytest.fixture +def local_attachment_file( + temp_attachments_dir: str, +) -> Generator[Tuple[uuid.UUID, str, str], None, None]: + """Creates a local attachment file in the temporary attachments directory. + + Args: + temp_attachments_dir: The temporary attachments directory. + + Returns: + A tuple containing the attachment ID, file name, and file content. + """ + attachment_id = uuid.uuid4() + file_name = "test_local_file.txt" + file_content = "Local test content" + + # Create the local file with the format {uuid}_{filename} + file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") + with open(file_path, "w") as f: + f.write(file_content) + + yield attachment_id, file_name, file_content + + # Cleanup is handled by temp_attachments_dir fixture class TestJobsService: @@ -53,9 +132,7 @@ def test_retrieve( assert job.id == 123 sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - + assert sent_request is not None assert sent_request.method == "GET" assert ( sent_request.url @@ -99,9 +176,7 @@ async def test_retrieve_async( assert job.id == 123 sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - + assert sent_request is not None assert sent_request.method == "GET" assert ( sent_request.url @@ -133,9 +208,7 @@ def test_resume_with_inbox_id( service.resume(inbox_id=inbox_id, payload=payload) sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - + assert sent_request is not None assert sent_request.method == "POST" assert ( sent_request.url @@ -176,9 +249,7 @@ def test_resume_with_job_id( service.resume(job_id=job_id, payload=payload) sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - + assert sent_requests is not None assert sent_requests[1].method == "POST" assert ( sent_requests[1].url @@ -212,9 +283,7 @@ async def test_resume_async_with_inbox_id( await service.resume_async(inbox_id=inbox_id, payload=payload) sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - + assert sent_request is not None assert sent_request.method == "POST" assert ( sent_request.url @@ -256,9 +325,7 @@ async def test_resume_async_with_job_id( await service.resume_async(job_id=job_id, payload=payload) sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - + assert sent_requests is not None assert sent_requests[1].method == "POST" assert ( sent_requests[1].url @@ -279,49 +346,37 @@ def test_list_attachments( base_url: str, org: str, tenant: str, - version: str, ) -> None: - # Arrange job_key = uuid.uuid4() - # Mock with query parameters httpx_mock.add_response( url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", method="GET", status_code=200, json=[ { - "Name": "document1.pdf", - "Key": "12345678-1234-1234-1234-123456789012", - "CreationTime": "2023-01-01T12:00:00Z", - "LastModificationTime": "2023-01-02T12:00:00Z", + "attachmentId": "12345678-1234-1234-1234-123456789012", + "creationTime": "2023-01-01T12:00:00Z", + "lastModificationTime": "2023-01-02T12:00:00Z", }, { - "Name": "document2.pdf", - "Key": "87654321-1234-1234-1234-123456789012", - "CreationTime": "2023-01-03T12:00:00Z", - "LastModificationTime": "2023-01-04T12:00:00Z", + "attachmentId": "87654321-1234-1234-1234-123456789012", + "creationTime": "2023-01-03T12:00:00Z", + "lastModificationTime": "2023-01-04T12:00:00Z", }, ], ) - # Act attachments = service.list_attachments(job_key=job_key) - # Assert assert len(attachments) == 2 - assert isinstance(attachments[0], Attachment) - assert attachments[0].name == "document1.pdf" - assert attachments[0].key == uuid.UUID("12345678-1234-1234-1234-123456789012") - assert isinstance(attachments[1], Attachment) - assert attachments[1].name == "document2.pdf" - assert attachments[1].key == uuid.UUID("87654321-1234-1234-1234-123456789012") - - # Verify the request - request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") + assert isinstance(attachments[0], str) + assert attachments[0] == "12345678-1234-1234-1234-123456789012" + assert isinstance(attachments[1], str) + assert attachments[1] == "87654321-1234-1234-1234-123456789012" + request = httpx_mock.get_request() + assert request is not None assert request.method == "GET" assert ( request.url.path @@ -340,46 +395,36 @@ async def test_list_attachments_async( tenant: str, version: str, ) -> None: - # Arrange job_key = uuid.uuid4() - # Mock with query parameters httpx_mock.add_response( url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", method="GET", status_code=200, json=[ { - "Name": "document1.pdf", - "Key": "12345678-1234-1234-1234-123456789012", - "CreationTime": "2023-01-01T12:00:00Z", - "LastModificationTime": "2023-01-02T12:00:00Z", + "attachmentId": "12345678-1234-1234-1234-123456789012", + "creationTime": "2023-01-01T12:00:00Z", + "lastModificationTime": "2023-01-02T12:00:00Z", }, { - "Name": "document2.pdf", - "Key": "87654321-1234-1234-1234-123456789012", - "CreationTime": "2023-01-03T12:00:00Z", - "LastModificationTime": "2023-01-04T12:00:00Z", + "attachmentId": "87654321-1234-1234-1234-123456789012", + "creationTime": "2023-01-03T12:00:00Z", + "lastModificationTime": "2023-01-04T12:00:00Z", }, ], ) - # Act attachments = await service.list_attachments_async(job_key=job_key) - # Assert assert len(attachments) == 2 - assert isinstance(attachments[0], Attachment) - assert attachments[0].name == "document1.pdf" - assert attachments[0].key == uuid.UUID("12345678-1234-1234-1234-123456789012") - assert isinstance(attachments[1], Attachment) - assert attachments[1].name == "document2.pdf" - assert attachments[1].key == uuid.UUID("87654321-1234-1234-1234-123456789012") - - # Verify the request + assert isinstance(attachments[0], str) + assert attachments[0] == "12345678-1234-1234-1234-123456789012" + assert isinstance(attachments[1], str) + assert attachments[1] == "87654321-1234-1234-1234-123456789012" + request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") + assert request is not None assert request.method == "GET" assert ( request.url.path @@ -397,7 +442,6 @@ def test_link_attachment( tenant: str, version: str, ) -> None: - # Arrange attachment_key = uuid.uuid4() job_key = uuid.uuid4() category = "Result" @@ -408,15 +452,12 @@ def test_link_attachment( status_code=200, ) - # Act service.link_attachment( attachment_key=attachment_key, job_key=job_key, category=category ) - # Verify the request request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") + assert request is not None assert request.method == "POST" assert ( request.url @@ -424,7 +465,6 @@ def test_link_attachment( ) assert HEADER_USER_AGENT in request.headers - # Verify request JSON body body = json.loads(request.content) assert body["attachmentId"] == str(attachment_key) assert body["jobKey"] == str(job_key) @@ -440,7 +480,6 @@ async def test_link_attachment_async( tenant: str, version: str, ) -> None: - # Arrange attachment_key = uuid.uuid4() job_key = uuid.uuid4() category = "Result" @@ -451,16 +490,12 @@ async def test_link_attachment_async( status_code=200, ) - # Act await service.link_attachment_async( attachment_key=attachment_key, job_key=job_key, category=category ) - # Verify the request request = httpx_mock.get_request() - if request is None: - raise Exception("No request was sent") - + assert request is not None assert request.method == "POST" assert ( request.url @@ -468,8 +503,429 @@ async def test_link_attachment_async( ) assert HEADER_USER_AGENT in request.headers - # Verify request JSON body body = json.loads(request.content) assert body["attachmentId"] == str(attachment_key) assert body["jobKey"] == str(job_key) assert body["category"] == category + + def test_create_job_attachment_with_job( + self, + service: JobsService, + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment when a job is available. + + This tests that the attachment is created in UiPath and linked to the job + when a job key is provided. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment(name=name, content=content, job_key=job_key) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + def test_create_job_attachment_with_job_context( + self, + config: Config, + execution_context: ExecutionContext, + monkeypatch: "MonkeyPatch", + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment when a job is available in the context. + + This tests that the attachment is created in UiPath and linked to the job + when a job key is available in the execution context. + + Args: + config: Config fixture. + execution_context: ExecutionContext fixture. + monkeypatch: MonkeyPatch fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = uuid.uuid4() + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Set job key in execution context - add attribute if it doesn't exist + if not hasattr(execution_context, "job_key"): + # Add job_key attribute to ExecutionContext + execution_context.__dict__["job_key"] = job_key + else: + execution_context.job_key = job_key + + # Create service with our execution context + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + service = JobsService(config=config, execution_context=execution_context) + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment(name=name, content=content) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=job_key, + category=None, + folder_key=None, + folder_path=None, + ) + + def test_create_job_attachment_no_job( + self, + service: JobsService, + temp_attachments_dir: str, + ) -> None: + """Test creating a job attachment when no job is available. + + This tests that the attachment is stored locally when no job key is provided + or available in the context. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + """ + # Arrange + content = "Test local attachment content" + name = "test_local_attachment.txt" + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = service.create_attachment(name=name, content=content) + + # Assert + assert isinstance(result, uuid.UUID) + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == content + + def test_create_job_attachment_from_file( + self, + service: JobsService, + temp_attachments_dir: str, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment from a file when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + source_content, source_name, source_path = temp_file + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = service.create_attachment(name=source_name, source_path=source_path) + + # Assert + assert isinstance(result, uuid.UUID) + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == source_content + + def test_create_job_attachment_validation_errors( + self, + service: JobsService, + ) -> None: + """Test validation errors in create_job_attachment. + + Args: + service: JobsService fixture. + """ + # Test missing both content and source_path + with pytest.raises(ValueError, match="Content or source_path is required"): + service.create_attachment(name="test.txt") + + # Test providing both content and source_path + with pytest.raises( + ValueError, match="Content and source_path are mutually exclusive" + ): + service.create_attachment( + name="test.txt", content="test content", source_path="/path/to/file.txt" + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_with_job( + self, + service: JobsService, + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment asynchronously when a job is available. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Mock the attachment service's upload_async method + # Create a mock that returns a coroutine returning a UUID + async_mock = mocker.AsyncMock(return_value=attachment_key) + mocker.patch.object( + service._attachments_service, "upload_async", side_effect=async_mock + ) + + # Mock the link_attachment_async method + mock_link = mocker.patch.object( + service, "link_attachment_async", side_effect=mocker.AsyncMock() + ) + + # Act + result = await service.create_attachment_async( + name=name, content=content, job_key=job_key + ) + + # Assert + assert result == attachment_key + async_mock.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_no_job( + self, + service: JobsService, + temp_attachments_dir: str, + ) -> None: + """Test creating a job attachment asynchronously when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + """ + # Arrange + content = "Test local attachment content async" + name = "test_local_attachment_async.txt" + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = await service.create_attachment_async(name=name, content=content) + + # Assert + assert isinstance(result, uuid.UUID) + + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == content + + def test_create_job_attachment_with_job_from_file( + self, + service: JobsService, + mocker: MockerFixture, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment from a file when a job is available. + + This tests that the attachment is created in UiPath from a file and linked to the job + when a job key is provided. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment( + name=source_name, source_path=source_path, job_key=job_key + ) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=source_name, + source_path=source_path, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_with_job_from_file( + self, + service: JobsService, + mocker: MockerFixture, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment asynchronously from a file when a job is available. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Mock the attachment service's upload_async method + async_mock = mocker.AsyncMock(return_value=attachment_key) + mocker.patch.object( + service._attachments_service, "upload_async", side_effect=async_mock + ) + + # Mock the link_attachment_async method + mock_link = mocker.patch.object( + service, "link_attachment_async", side_effect=mocker.AsyncMock() + ) + + # Act + result = await service.create_attachment_async( + name=source_name, source_path=source_path, job_key=job_key + ) + + # Assert + assert result == attachment_key + async_mock.assert_called_once_with( + name=source_name, + source_path=source_path, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_from_file( + self, + service: JobsService, + temp_attachments_dir: str, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment asynchronously from a file when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = await service.create_attachment_async( + name=source_name, source_path=source_path + ) + + # Assert + assert isinstance(result, uuid.UUID) + + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == source_content