diff --git a/src/uipath/_cli/_runtime/_hitl.py b/src/uipath/_cli/_runtime/_hitl.py index accf0b33f..0336f6008 100644 --- a/src/uipath/_cli/_runtime/_hitl.py +++ b/src/uipath/_cli/_runtime/_hitl.py @@ -78,7 +78,7 @@ async def read(cls, resume_trigger: UiPathResumeTrigger) -> Optional[str]: case UiPathResumeTriggerType.JOB: if resume_trigger.item_key: job = await uipath.jobs.retrieve_async( - resume_trigger.item_key, + job_key=resume_trigger.item_key, folder_key=resume_trigger.folder_key, folder_path=resume_trigger.folder_path, ) diff --git a/src/uipath/_resources/CLI_REFERENCE.md b/src/uipath/_resources/CLI_REFERENCE.md index bf55cf5d2..8ca4c53c0 100644 --- a/src/uipath/_resources/CLI_REFERENCE.md +++ b/src/uipath/_resources/CLI_REFERENCE.md @@ -230,64 +230,65 @@ The UiPath CLI provides commands for interacting with UiPath platform services. Manage UiPath storage buckets and files. - Buckets are cloud storage containers for files used by automation processes. - +Buckets are cloud storage containers for files used by automation processes. + + +Bucket Operations: + list - List all buckets + create - Create a new bucket + delete - Delete a bucket + retrieve - Get bucket details + exists - Check if bucket exists + + +File Operations (use 'buckets files' subcommand): + files list - List files in a bucket + files search - Search files using glob patterns + files upload - Upload a file to a bucket + files download - Download a file from a bucket + files delete - Delete a file from a bucket + files exists - Check if a file exists + + +Examples:  - Bucket Operations: - list - List all buckets - create - Create a new bucket - delete - Delete a bucket - retrieve - Get bucket details - exists - Check if bucket exists - + # Bucket operations with explicit folder + uipath buckets list --folder-path "Shared" + uipath buckets create my-bucket --description "Data storage" + uipath buckets exists my-bucket + uipath buckets delete my-bucket --confirm  - File Operations (use 'buckets files' subcommand): - files list - List files in a bucket - files search - Search files using glob patterns - files upload - Upload a file to a bucket - files download - Download a file from a bucket - files delete - Delete a file from a bucket - files exists - Check if a file exists - + # Using environment variable for folder context + export UIPATH_FOLDER_PATH="Shared" + uipath buckets list + uipath buckets create my-bucket --description "Data storage"  - Examples: - # Bucket operations - uipath buckets list --folder-path "Shared" - uipath buckets create my-bucket --description "Data storage" - uipath buckets exists my-bucket - uipath buckets delete my-bucket --confirm + # File operations + uipath buckets files list my-bucket + uipath buckets files search my-bucket "*.pdf" + uipath buckets files upload my-bucket ./data.csv remote/data.csv + uipath buckets files download my-bucket data.csv ./local.csv + uipath buckets files delete my-bucket old-data.csv --confirm + uipath buckets files exists my-bucket data.csv - # File operations - uipath buckets files list my-bucket - uipath buckets files search my-bucket "*.pdf" - uipath buckets files upload my-bucket ./data.csv remote/data.csv - uipath buckets files download my-bucket data.csv ./local.csv - uipath buckets files delete my-bucket old-data.csv --confirm - uipath buckets files exists my-bucket data.csv - **Subcommands:** **`uipath buckets create`** -Create a new bucket. +Create a new Bucket. -  - Arguments: - NAME: Name of the bucket to create +Examples: + uipath buckets create my-resource + uipath buckets create my-resource --folder-path Shared -  - Examples: - uipath buckets create my-bucket - uipath buckets create reports --description "Monthly reports storage" - Arguments: - `name` (required): N/A Options: -- `--description`: Bucket description (default: `Sentinel.UNSET`) -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--description`: Bucket description +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -296,14 +297,10 @@ Options: Delete a bucket. -  - Arguments: - NAME: Name of the bucket to delete -  Examples: - uipath buckets delete old-bucket --confirm - uipath buckets delete test-bucket --dry-run + uipath buckets delete my-bucket --confirm + uipath buckets delete my-bucket --dry-run Arguments: @@ -311,31 +308,26 @@ Arguments: Options: - `--confirm`: Skip confirmation prompt -- `--dry-run`: Show what would be deleted -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--dry-run`: Show what would be deleted without deleting +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) **`uipath buckets exists`** -Check if a bucket exists. +Check if a Bucket exists. -  - Arguments: - NAME: Name of the bucket to check +Examples: + uipath buckets exists my-resource + uipath buckets exists my-resource --folder-path Shared -  - Examples: - uipath buckets exists my-bucket - uipath buckets exists reports --folder-path "Production" - Arguments: - `name` (required): N/A Options: -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -346,21 +338,22 @@ Manage files within buckets.  Examples: +  # List files in a bucket uipath buckets files list my-bucket - +  # Search for files with glob pattern uipath buckets files search my-bucket "*.pdf" - +  # Upload a file uipath buckets files upload my-bucket ./data.csv remote/data.csv - +  # Download a file uipath buckets files download my-bucket data.csv ./local.csv - +  # Delete a file uipath buckets files delete my-bucket old-data.csv --confirm - +  # Check if file exists uipath buckets files exists my-bucket data.csv @@ -387,7 +380,7 @@ Arguments: Options: - `--confirm`: Skip confirmation prompt - `--dry-run`: Show what would be deleted -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -414,7 +407,7 @@ Arguments: - `local_path` (required): N/A Options: -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -439,7 +432,7 @@ Arguments: - `file_path` (required): N/A Options: -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -468,7 +461,7 @@ Options: - `--limit`: Maximum number of files to return (default: `Sentinel.UNSET`) - `--offset`: Number of files to skip (default: `0`) - `--all`: Fetch all files (auto-paginate) -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -499,7 +492,7 @@ Options: - `--prefix`: Directory path to search in (default: ``) - `--recursive`: Search subdirectories recursively - `--limit`: Maximum number of files to return (default: `Sentinel.UNSET`) -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -526,32 +519,24 @@ Arguments: - `remote_path` (required): N/A Options: -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) **`uipath buckets list`** -List all buckets in a folder. +List all Buckets. - The SDK provides an auto-paginating iterator over all buckets. - The CLI applies client-side slicing using --limit and --offset to control - which results are displayed. +Examples: + uipath buckets list + uipath buckets list --folder-path Shared -  - Examples: - uipath buckets list - uipath buckets list --folder-path "Production" - uipath buckets list --limit 10 --format json - uipath buckets list --all # Fetch all buckets with auto-pagination - Options: - `--limit`: Maximum number of items to return (default: `Sentinel.UNSET`) - `--offset`: Number of items to skip (default: `0`) -- `--all`: Fetch all items (auto-paginate) -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) @@ -569,7 +554,7 @@ Retrieve a bucket by name or key. Options: - `--name`: Bucket name (default: `Sentinel.UNSET`) - `--key`: Bucket key (UUID) (default: `Sentinel.UNSET`) -- `--folder-path`: Folder path (e.g., "Shared") (default: `Sentinel.UNSET`) +- `--folder-path`: Folder path (e.g., "Shared"). Can also be set via UIPATH_FOLDER_PATH environment variable. (default: `Sentinel.UNSET`) - `--folder-key`: Folder key (UUID) (default: `Sentinel.UNSET`) - `--format`: Output format (overrides global) (default: `Sentinel.UNSET`) - `--output`, `-o`: Output file (overrides global) (default: `Sentinel.UNSET`) diff --git a/src/uipath/_resources/SDK_REFERENCE.md b/src/uipath/_resources/SDK_REFERENCE.md index 60011217d..e8b90895e 100644 --- a/src/uipath/_resources/SDK_REFERENCE.md +++ b/src/uipath/_resources/SDK_REFERENCE.md @@ -50,6 +50,36 @@ service = sdk.api_client Assets service ```python +# Create a new asset. +sdk.assets.create(name: str, value: Union[str, int, bool, Dict[str, Any]], value_type: str, description: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.assets.Asset + +# Async version of create(). +sdk.assets.create_async(name: str, value: Union[str, int, bool, Dict[str, Any]], value_type: str, description: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.assets.Asset + +# Delete an asset. +sdk.assets.delete(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + +# Async version of delete(). +sdk.assets.delete_async(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + +# Check if asset exists. +sdk.assets.exists(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# Async version of exists(). +sdk.assets.exists_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# Get the value of an asset (convenience method). +sdk.assets.get_value(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Union[str, int, bool, typing.Dict[str, typing.Any]] + +# Async version of get_value(). +sdk.assets.get_value_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.Union[str, int, bool, typing.Dict[str, typing.Any]] + +# List assets with automatic pagination (limited to 10 pages). +sdk.assets.list(folder_path: Optional[str]=None, folder_key: Optional[str]=None, name: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.assets.Asset] + +# Async version of list() with pagination limit. +sdk.assets.list_async(folder_path: Optional[str]=None, folder_key: Optional[str]=None, name: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.assets.Asset] + # Retrieve an asset by its name. sdk.assets.retrieve(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.models.assets.UserAsset | uipath.models.assets.Asset @@ -180,10 +210,10 @@ sdk.connections.list(name: Optional[str]=None, folder_path: Optional[str]=None, sdk.connections.list_async(name: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, connector_key: Optional[str]=None, skip: Optional[int]=None, top: Optional[int]=None) -> typing.List[uipath.models.connections.Connection] # Synchronously retrieve connection API metadata. -sdk.connections.metadata(element_instance_id: int, tool_path: str, schema_mode: bool=True) -> uipath.models.connections.ConnectionMetadata +sdk.connections.metadata(element_instance_id: int, connector_key: str, tool_path: str, parameters: Optional[Dict[str, str]]=None, schema_mode: bool=True, max_jit_depth: int=5) -> uipath.models.connections.ConnectionMetadata # Asynchronously retrieve connection API metadata. -sdk.connections.metadata_async(element_instance_id: int, tool_path: str, schema_mode: bool=True) -> uipath.models.connections.ConnectionMetadata +sdk.connections.metadata_async(element_instance_id: int, connector_key: str, tool_path: str, parameters: Optional[Dict[str, str]]=None, schema_mode: bool=True, max_jit_depth: int=5) -> uipath.models.connections.ConnectionMetadata # Retrieve connection details by its key. sdk.connections.retrieve(key: str) -> uipath.models.connections.Connection @@ -333,6 +363,30 @@ sdk.entities.update_records_async(entity_key: str, records: List[Any], schema: O Folders service ```python +# Check if folder exists. +sdk.folders.exists(key: Optional[str]=None, display_name: Optional[str]=None) -> bool + +# Async version of exists(). +sdk.folders.exists_async(key: Optional[str]=None, display_name: Optional[str]=None) -> bool + +# List folders with auto-pagination. +sdk.folders.list(filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.folders.Folder] + +# Async version of list(). +sdk.folders.list_async(filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.folders.Folder] + +# Retrieve a folder by key or display name. +sdk.folders.retrieve(key: Optional[str]=None, display_name: Optional[str]=None) -> uipath.models.folders.Folder + +# Async version of retrieve(). +sdk.folders.retrieve_async(key: Optional[str]=None, display_name: Optional[str]=None) -> uipath.models.folders.Folder + +# Retrieve a folder by its fully qualified path. +sdk.folders.retrieve_by_path(folder_path: str) -> uipath.models.folders.Folder + +# Async version of retrieve_by_path(). +sdk.folders.retrieve_by_path_async(folder_path: str) -> uipath.models.folders.Folder + # Retrieve the folder key by folder path with pagination support. sdk.folders.retrieve_key(folder_path: str) -> typing.Optional[str] @@ -349,6 +403,12 @@ sdk.jobs.create_attachment(name: str, content: Union[str, bytes, NoneType]=None, # Create and upload an attachment asynchronously, optionally linking it to a job. sdk.jobs.create_attachment_async(name: str, content: Union[str, bytes, NoneType]=None, source_path: Union[str, pathlib.Path, NoneType]=None, job_key: Union[str, uuid.UUID, NoneType]=None, category: Optional[str]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uuid.UUID +# Check if job exists. +sdk.jobs.exists(job_key: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# Async version of exists(). +sdk.jobs.exists_async(job_key: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + # Get the actual output data, downloading from attachment if necessary. sdk.jobs.extract_output(job: uipath.models.job.Job) -> typing.Optional[str] @@ -361,6 +421,12 @@ sdk.jobs.link_attachment(attachment_key: uuid.UUID, job_key: uuid.UUID, category # Link an attachment to a job asynchronously. sdk.jobs.link_attachment_async(attachment_key: uuid.UUID, job_key: uuid.UUID, category: Optional[str]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) +# List jobs with automatic pagination (limited to 10 pages). +sdk.jobs.list(folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.job.Job] + +# Async version of list() with pagination limit. +sdk.jobs.list_async(folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.job.Job] + # List attachments associated with a specific job. sdk.jobs.list_attachments(job_key: uuid.UUID, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> typing.List[str] @@ -373,7 +439,7 @@ sdk.jobs.resume(inbox_id: Optional[str]=None, job_id: Optional[str]=None, folder # Asynchronously sends a payload to resume a paused job waiting for input, identified by its inbox ID. sdk.jobs.resume_async(inbox_id: Optional[str]=None, job_id: Optional[str]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None, payload: Any) -> None -# Retrieve a job identified by its key. +# Retrieve a single job by key. sdk.jobs.retrieve(job_key: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.models.job.Job # Fetch payload data for API triggers. @@ -382,9 +448,15 @@ sdk.jobs.retrieve_api_payload(inbox_id: str) -> typing.Any # Asynchronously fetch payload data for API triggers. sdk.jobs.retrieve_api_payload_async(inbox_id: str) -> typing.Any -# Asynchronously retrieve a job identified by its key. +# Asynchronously retrieve a single job by key. sdk.jobs.retrieve_async(job_key: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.models.job.Job +# Stop one or more jobs with specified strategy. +sdk.jobs.stop(job_keys: List[str], strategy: str="SoftStop", folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + +# Async version of stop() - stop one or more jobs with specified strategy. +sdk.jobs.stop_async(job_keys: List[str], strategy: str="SoftStop", folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + ``` ### Llm @@ -415,12 +487,30 @@ sdk.llm_openai.embeddings(input: str, embedding_model: str="text-embedding-ada-0 Processes service ```python +# Check if process exists. +sdk.processes.exists(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# Async version of exists(). +sdk.processes.exists_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + # Start execution of a process by its name. sdk.processes.invoke(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.models.job.Job # Asynchronously start execution of a process by its name. sdk.processes.invoke_async(name: str, input_arguments: Optional[Dict[str, Any]]=None, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> uipath.models.job.Job +# List processes with auto-pagination. +sdk.processes.list(folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.processes.Process] + +# Async version of list(). +sdk.processes.list_async(folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.processes.Process] + +# Retrieve a process by name or key. +sdk.processes.retrieve(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.processes.Process + +# Async version of retrieve(). +sdk.processes.retrieve_async(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.processes.Process + ``` ### Queues @@ -434,11 +524,17 @@ sdk.queues.complete_transaction_item(transaction_key: str, result: Union[Dict[st # Asynchronously completes a transaction item with the specified result. sdk.queues.complete_transaction_item_async(transaction_key: str, result: Union[Dict[str, Any], uipath.models.queues.TransactionItemResult]) -> httpx.Response -# Creates a new queue item in the Orchestrator. -sdk.queues.create_item(item: Union[Dict[str, Any], uipath.models.queues.QueueItem]) -> httpx.Response +# Create a new queue definition. +sdk.queues.create_definition(name: str, description: Optional[str]=None, max_number_of_retries: int=0, accept_automatically_retry: bool=False, enforce_unique_reference: bool=False, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueDefinition + +# Async version of create_definition(). +sdk.queues.create_definition_async(name: str, description: Optional[str]=None, max_number_of_retries: int=0, accept_automatically_retry: bool=False, enforce_unique_reference: bool=False, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueDefinition + +# Creates a new queue item with explicit parameters. +sdk.queues.create_item(queue_name: Optional[str]=None, queue_key: Optional[str]=None, reference: str, specific_content: Dict[str, Any], priority: Optional[str]=None, defer_date: Optional[datetime.datetime]=None, due_date: Optional[datetime.datetime]=None, risk_sla_date: Optional[datetime.datetime]=None, progress: Optional[str]=None, source: Optional[str]=None, parent_operation_id: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueItem -# Asynchronously creates a new queue item in the Orchestrator. -sdk.queues.create_item_async(item: Union[Dict[str, Any], uipath.models.queues.QueueItem]) -> httpx.Response +# Asynchronously creates a new queue item with explicit parameters. +sdk.queues.create_item_async(queue_name: Optional[str]=None, queue_key: Optional[str]=None, reference: str, specific_content: Dict[str, Any], priority: Optional[str]=None, defer_date: Optional[datetime.datetime]=None, due_date: Optional[datetime.datetime]=None, risk_sla_date: Optional[datetime.datetime]=None, progress: Optional[str]=None, source: Optional[str]=None, parent_operation_id: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueItem # Creates multiple queue items in bulk. sdk.queues.create_items(items: List[Union[Dict[str, Any], uipath.models.queues.QueueItem]], queue_name: str, commit_type: httpx.Response @@ -452,11 +548,35 @@ sdk.queues.create_transaction_item(item: Union[Dict[str, Any], uipath.models.que # Asynchronously creates a new transaction item in a queue. sdk.queues.create_transaction_item_async(item: Union[Dict[str, Any], uipath.models.queues.TransactionItem], no_robot: bool=False) -> httpx.Response -# Retrieves a list of queue items from the Orchestrator. -sdk.queues.list_items() -> httpx.Response +# Delete a queue definition. +sdk.queues.delete_definition(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + +# Async version of delete_definition(). +sdk.queues.delete_definition_async(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> None + +# Check if queue definition exists. +sdk.queues.exists_definition(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# Async version of exists_definition(). +sdk.queues.exists_definition_async(name: str, folder_key: Optional[str]=None, folder_path: Optional[str]=None) -> bool + +# List queue definitions with auto-pagination. +sdk.queues.list_definitions(folder_path: Optional[str]=None, folder_key: Optional[str]=None, name: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.queues.QueueDefinition] + +# Async version of list_definitions(). +sdk.queues.list_definitions_async(folder_path: Optional[str]=None, folder_key: Optional[str]=None, name: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.queues.QueueDefinition] + +# List queue items with server-side filtering and auto-pagination. +sdk.queues.list_items(queue_name: Optional[str]=None, queue_key: Optional[str]=None, status: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.Iterator[uipath.models.queues.QueueItem] + +# Async version of list_items() with server-side filtering and auto-pagination. +sdk.queues.list_items_async(queue_name: Optional[str]=None, queue_key: Optional[str]=None, status: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None, filter: Optional[str]=None, orderby: Optional[str]=None, top: int=100, skip: int=0) -> typing.AsyncIterator[uipath.models.queues.QueueItem] + +# Retrieve a queue definition by name or key. +sdk.queues.retrieve_definition(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueDefinition -# Asynchronously retrieves a list of queue items from the Orchestrator. -sdk.queues.list_items_async() -> httpx.Response +# Async version of retrieve_definition(). +sdk.queues.retrieve_definition_async(name: Optional[str]=None, key: Optional[str]=None, folder_path: Optional[str]=None, folder_key: Optional[str]=None) -> uipath.models.queues.QueueDefinition # Updates the progress of a transaction item. sdk.queues.update_progress_of_transaction_item(transaction_key: str, progress: str) -> httpx.Response diff --git a/src/uipath/_services/_base_service.py b/src/uipath/_services/_base_service.py index 5f06c7c83..5c4d5a002 100644 --- a/src/uipath/_services/_base_service.py +++ b/src/uipath/_services/_base_service.py @@ -1,4 +1,8 @@ import inspect +import random +import time +from datetime import datetime +from email.utils import parsedate_to_datetime from logging import getLogger from typing import Any, Literal, Union @@ -36,6 +40,8 @@ def is_retryable_status_code(response: Response) -> bool: class BaseService: + MAX_RETRIES = 3 + def __init__(self, config: Config, execution_context: ExecutionContext) -> None: self._logger = getLogger("uipath") self._config = config @@ -58,6 +64,31 @@ def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__() + def _parse_retry_after(self, headers: Headers) -> float: + """Parse Retry-After header (RFC 6585/7231). + + Args: + headers: HTTP response headers + + Returns: + float: Seconds to wait before retry (minimum 1.0) + """ + retry_after = headers.get("Retry-After") + if not retry_after: + return 1.0 + + try: + return float(retry_after) + except ValueError: + pass + + try: + retry_date = parsedate_to_datetime(retry_after) + delta = (retry_date - datetime.now(retry_date.tzinfo)).total_seconds() + return max(delta, 1.0) + except (ValueError, TypeError): + return 1.0 + @retry( retry=( retry_if_exception(is_retryable_exception) @@ -102,7 +133,23 @@ def request( scoped_url = self._url.scope_url(str(url), scoped) - response = self._client.request(method, scoped_url, **kwargs) + for attempt in range(self.MAX_RETRIES + 1): + response = self._client.request(method, scoped_url, **kwargs) + + if response.status_code == 429: + if attempt < self.MAX_RETRIES: + retry_after = self._parse_retry_after(response.headers) + jitter = random.uniform(0, 0.1 * retry_after) + sleep_time = retry_after + jitter + self._logger.warning( + f"Rate limited (429). Retrying after {sleep_time:.2f}s " + f"(attempt {attempt + 1}/{self.MAX_RETRIES})" + ) + time.sleep(sleep_time) + continue + break + + break try: response.raise_for_status() @@ -127,6 +174,8 @@ async def request_async( scoped: Literal["org", "tenant"] = "tenant", **kwargs: Any, ) -> Response: + import asyncio + self._logger.debug(f"Request: {method} {url}") self._logger.debug( f"HEADERS: {kwargs.get('headers', self._client_async.headers)}" @@ -139,7 +188,23 @@ async def request_async( scoped_url = self._url.scope_url(str(url), scoped) - response = await self._client_async.request(method, scoped_url, **kwargs) + for attempt in range(self.MAX_RETRIES + 1): + response = await self._client_async.request(method, scoped_url, **kwargs) + + if response.status_code == 429: + if attempt < self.MAX_RETRIES: + retry_after = self._parse_retry_after(response.headers) + jitter = random.uniform(0, 0.1 * retry_after) + sleep_time = retry_after + jitter + self._logger.warning( + f"Rate limited (429). Retrying after {sleep_time:.2f}s " + f"(attempt {attempt + 1}/{self.MAX_RETRIES})" + ) + await asyncio.sleep(sleep_time) + continue + break + + break try: response.raise_for_status() diff --git a/src/uipath/_services/assets_service.py b/src/uipath/_services/assets_service.py index 479459b51..ff6314b30 100644 --- a/src/uipath/_services/assets_service.py +++ b/src/uipath/_services/assets_service.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Union from httpx import Response @@ -7,6 +7,7 @@ from .._folder_context import FolderContext from .._utils import Endpoint, RequestSpec, header_folder, resource_override from ..models import Asset, UserAsset +from ..models.errors import PaginationLimitError from ..tracing._traced import traced from ._base_service import BaseService @@ -76,7 +77,10 @@ def retrieve( if is_user: return UserAsset.model_validate(response.json()) else: - return Asset.model_validate(response.json()["value"][0]) + items = response.json().get("value", []) + if not items: + raise LookupError(f"Asset with name '{name}' not found.") + return Asset.model_validate(items[0]) @traced( name="assets_retrieve", run_type="uipath", hide_input=True, hide_output=True @@ -123,7 +127,10 @@ async def retrieve_async( if is_user: return UserAsset.model_validate(response.json()) else: - return Asset.model_validate(response.json()["value"][0]) + items = response.json().get("value", []) + if not items: + raise LookupError(f"Asset with name '{name}' not found.") + return Asset.model_validate(items[0]) @traced( name="assets_credential", run_type="uipath", hide_input=True, hide_output=True @@ -312,6 +319,439 @@ async def update_async( return response.json() + @traced(name="assets_list", run_type="uipath") + def list( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[Asset]: + """List assets with automatic pagination (limited to 10 pages). + + Args: + folder_path: Folder path to filter assets + folder_key: Folder key (mutually exclusive with folder_path) + name: Filter by asset name (contains match) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + Asset: Asset resource instances + + Raises: + PaginationLimitError: If more than 10 pages (1,000 items) exist. + Use filters or manual pagination to retrieve additional results. + + Note: + Auto-pagination is limited to 10 pages (~1,000 items) to prevent + performance issues with deep OFFSET queries. If you hit this limit: + + 1. Add filters to narrow results: + >>> for asset in sdk.assets.list(filter="ValueType eq 'Text'"): + ... print(asset.name) + + Examples: + >>> # List all assets (up to 1,000) + >>> for asset in sdk.assets.list(): + ... print(asset.name, asset.value) + >>> + >>> # Filter by name + >>> for asset in sdk.assets.list(name="API"): + ... print(asset.name) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + asset = Asset.model_validate(item) + yield asset + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list", + current_skip=current_skip, + filter_example="ValueType eq 'Text'", + ) + + @traced(name="assets_list", run_type="uipath") + async def list_async( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[Asset]: + """Async version of list() with pagination limit. + + See list() for full documentation. + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + asset = Asset.model_validate(item) + yield asset + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_async", + current_skip=current_skip, + filter_example="ValueType eq 'Text'", + ) + + @traced(name="assets_create", run_type="uipath") + def create( + self, + *, + name: str, + value: Union[str, int, bool, Dict[str, Any]], + value_type: str, + description: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Asset: + """Create a new asset. + + Args: + name: Asset name (must be unique within folder) + value: Asset value + value_type: Type of asset ("Text", "Integer", "Boolean", "Credential", "Secret") + - Text: Plain text values + - Integer: Numeric values + - Boolean: True/False values + - Credential: Username/password pairs (robot-context only) + - Secret: Encrypted single values like API keys (robot-context only) + description: Optional description + folder_path: Folder to create asset in + folder_key: Folder key + + Returns: + Asset: Newly created asset + + Examples: + >>> asset = sdk.assets.create( + ... name="API_Key", + ... value="secret123", + ... value_type="Text" + ... ) + """ + spec = self._create_spec( + name=name, + value=value, + value_type=value_type, + description=description, + folder_path=folder_path, + folder_key=folder_key, + ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + + return Asset.model_validate(response) + + @traced(name="assets_create", run_type="uipath") + async def create_async( + self, + *, + name: str, + value: Union[str, int, bool, Dict[str, Any]], + value_type: str, + description: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Asset: + """Async version of create(). + + Args: + name: Asset name (must be unique within folder) + value: Asset value + value_type: Type of asset ("Text", "Integer", "Boolean", "Credential", "Secret") + - Text: Plain text values + - Integer: Numeric values + - Boolean: True/False values + - Credential: Username/password pairs (robot-context only) + - Secret: Encrypted single values like API keys (robot-context only) + description: Optional description + folder_path: Folder to create asset in + folder_key: Folder key + + Returns: + Asset: Newly created asset + """ + spec = self._create_spec( + name=name, + value=value, + value_type=value_type, + description=description, + folder_path=folder_path, + folder_key=folder_key, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + + return Asset.model_validate(response) + + @traced(name="assets_delete", run_type="uipath") + @resource_override(resource_type="asset") + def delete( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Delete an asset. + + Args: + name: Asset name + key: Asset key (UUID) + folder_path: Folder path + folder_key: Folder key + + Returns: + None + + Examples: + >>> sdk.assets.delete(name="OldAsset") + """ + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + if name and not key: + asset = self.retrieve( + name=name, folder_path=folder_path, folder_key=folder_key + ) + if isinstance(asset, Asset): + key = asset.key + else: + raise ValueError("Cannot delete user assets via API") + + if not key: + raise ValueError( + f"Asset '{name}' was found, but it does not have a key and cannot be deleted." + ) + + spec = self._delete_spec( + asset_key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + @traced(name="assets_delete", run_type="uipath") + @resource_override(resource_type="asset") + async def delete_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Async version of delete().""" + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + if name and not key: + asset = await self.retrieve_async( + name=name, folder_path=folder_path, folder_key=folder_key + ) + if isinstance(asset, Asset): + key = asset.key + else: + raise ValueError("Cannot delete user assets via API") + + if not key: + raise ValueError( + f"Asset '{name}' was found, but it does not have a key and cannot be deleted." + ) + + spec = self._delete_spec( + asset_key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + @traced(name="assets_exists", run_type="uipath") + def exists( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if asset exists. + + Args: + name: Asset name + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if asset exists + + Examples: + >>> if sdk.assets.exists("API_Key"): + ... print("Asset found") + """ + try: + self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) + return True + except LookupError: + return False + + @traced(name="assets_exists", run_type="uipath") + async def exists_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Async version of exists().""" + try: + await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + + @traced(name="assets_get_value", run_type="uipath", hide_output=True) + def get_value( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Union[str, int, bool, Dict[str, Any]]: + """Get the value of an asset (convenience method). + + Args: + name: Asset name + folder_key: Folder key + folder_path: Folder path + + Returns: + The asset value (type depends on ValueType) + + Examples: + >>> api_key = sdk.assets.get_value("API_Key") + >>> db_port = sdk.assets.get_value("DB_Port") # Returns int + """ + asset = self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) + return asset.value + + @traced(name="assets_get_value", run_type="uipath", hide_output=True) + async def get_value_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Union[str, int, bool, Dict[str, Any]]: + """Async version of get_value().""" + asset = await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + return asset.value + @property def custom_headers(self) -> Dict[str, str]: return self.folder_headers @@ -371,3 +811,95 @@ def _update_spec( **header_folder(folder_key, folder_path), }, ) + + def _list_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + name: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing assets.""" + filters = [] + if name: + escaped_name = name.replace("'", "''") + filters.append(f"contains(tolower(Name), tolower('{escaped_name}'))") + if filter: + filters.append(filter) + + filter_str = " and ".join(filters) if filters else None + + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter_str: + params["$filter"] = filter_str + if orderby: + params["$orderby"] = orderby + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Assets"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _create_spec( + self, + name: str, + value: Union[str, int, bool, Dict[str, Any]], + value_type: str, + description: Optional[str], + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for creating asset.""" + body: Dict[str, Any] = { + "Name": name, + "ValueType": value_type, + } + + if value_type == "Text": + if isinstance(value, dict): + raise ValueError("Text assets cannot have dict values") + body["StringValue"] = str(value) + elif value_type == "Integer": + if isinstance(value, dict): + raise ValueError("Integer assets cannot have dict values") + body["IntValue"] = int(value) if not isinstance(value, int) else value + elif value_type == "Boolean" or value_type == "Bool": + if isinstance(value, dict): + raise ValueError("Boolean assets cannot have dict values") + body["BoolValue"] = bool(value) if not isinstance(value, bool) else value + else: + body["Value"] = value + + if description: + body["Description"] = description + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/odata/Assets"), + json=body, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _delete_spec( + self, + asset_key: str, + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for deleting asset by key (UUID).""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"/orchestrator_/odata/Assets('{asset_key}')"), + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/src/uipath/_services/attachments_service.py b/src/uipath/_services/attachments_service.py index 5da302e0e..9ccdfce1f 100644 --- a/src/uipath/_services/attachments_service.py +++ b/src/uipath/_services/attachments_service.py @@ -13,6 +13,7 @@ from .._utils import Endpoint, RequestSpec, header_folder from .._utils._ssl_context import get_httpx_client_kwargs from .._utils.constants import TEMP_ATTACHMENTS_FOLDER +from ..models.exceptions import EnrichedException from ..tracing._traced import traced from ._base_service import BaseService @@ -130,9 +131,9 @@ def download( file.write(chunk) return attachment_name - except Exception as e: + except EnrichedException as e: # If not found in UiPath, check local storage - if "404" in str(e): + if e.status_code == 404: # Check if file exists in temp directory if os.path.exists(self._temp_dir): # Look for any file starting with our UUID @@ -152,10 +153,11 @@ def download( 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 + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + raise @traced(name="attachments_download", run_type="uipath") async def download_async( @@ -248,9 +250,9 @@ async def main(): file.write(chunk) return attachment_name - except Exception as e: + except EnrichedException as e: # If not found in UiPath, check local storage - if "404" in str(e): + if e.status_code == 404: # Check if file exists in temp directory if os.path.exists(self._temp_dir): # Look for any file starting with our UUID @@ -270,10 +272,11 @@ async def main(): 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 + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + raise @overload def upload( @@ -595,9 +598,9 @@ def delete( url=spec.endpoint, headers=spec.headers, ) - except Exception as e: + except EnrichedException as e: # If not found in UiPath, check local storage - if "404" in str(e): + if e.status_code == 404: # Check if file exists in temp directory if os.path.exists(self._temp_dir): # Look for any file starting with our UUID @@ -610,10 +613,11 @@ def delete( 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 + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + raise @traced(name="attachments_delete", run_type="uipath") async def delete_async( @@ -667,9 +671,9 @@ async def main(): url=spec.endpoint, headers=spec.headers, ) - except Exception as e: + except EnrichedException as e: # If not found in UiPath, check local storage - if "404" in str(e): + if e.status_code == 404: # Check if file exists in temp directory if os.path.exists(self._temp_dir): # Look for any file starting with our UUID @@ -682,10 +686,11 @@ async def main(): 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 + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + raise @property def custom_headers(self) -> Dict[str, str]: diff --git a/src/uipath/_services/buckets_service.py b/src/uipath/_services/buckets_service.py index 67410f862..c34d3a56d 100644 --- a/src/uipath/_services/buckets_service.py +++ b/src/uipath/_services/buckets_service.py @@ -12,6 +12,7 @@ from .._utils import Endpoint, RequestSpec, header_folder, resource_override from .._utils._ssl_context import get_httpx_client_kwargs from ..models import Bucket, BucketFile +from ..models.errors import PaginationLimitError from ..tracing._traced import traced from ._base_service import BaseService @@ -61,10 +62,12 @@ def list( >>> for bucket in sdk.buckets.list(name="invoice"): ... print(bucket.name) """ + MAX_PAGES = 10 skip = 0 top = 100 + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._list_spec( folder_path=folder_path, folder_key=folder_key, @@ -87,11 +90,23 @@ def list( bucket = Bucket.model_validate(item) yield bucket + pages_fetched += 1 + if len(items) < top: break skip += top + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list", + current_skip=skip, + filter_example="name='specific-bucket'", + ) + @traced(name="buckets_list", run_type="uipath") async def list_async( self, @@ -101,10 +116,12 @@ async def list_async( name: Optional[str] = None, ) -> AsyncIterator[Bucket]: """Async version of list() with auto-pagination.""" + MAX_PAGES = 10 skip = 0 top = 50 + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._list_spec( folder_path=folder_path, folder_key=folder_key, @@ -129,11 +146,23 @@ async def list_async( bucket = Bucket.model_validate(item) yield bucket + pages_fetched += 1 + if len(items) < top: break skip += top + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_async", + current_skip=skip, + filter_example="name='specific-bucket'", + ) + @traced(name="buckets_exists", run_type="uipath") def exists( self, @@ -811,9 +840,11 @@ def list_files( name=name, key=key, folder_key=folder_key, folder_path=folder_path ) + MAX_PAGES = 10 continuation_token: Optional[str] = None + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._list_files_spec( bucket.id, prefix, @@ -832,10 +863,21 @@ def list_files( for item in items: yield BucketFile.model_validate(item) + pages_fetched += 1 continuation_token = response.get("continuationToken") if not continuation_token: break + else: + if continuation_token: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=500, + method_name="list_files", + current_skip=0, + filter_example="prefix='data/'", + ) + @traced(name="buckets_list_files", run_type="uipath") @resource_override(resource_type="bucket") async def list_files_async( @@ -874,9 +916,11 @@ async def list_files_async( name=name, key=key, folder_key=folder_key, folder_path=folder_path ) + MAX_PAGES = 10 continuation_token: Optional[str] = None + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._list_files_spec( bucket.id, prefix, @@ -897,10 +941,21 @@ async def list_files_async( for item in items: yield BucketFile.model_validate(item) + pages_fetched += 1 continuation_token = response.get("continuationToken") if not continuation_token: break + else: + if continuation_token: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=500, + method_name="list_files_async", + current_skip=0, + filter_example="prefix='data/'", + ) + @traced(name="buckets_exists_file", run_type="uipath") @resource_override(resource_type="bucket") def exists_file( @@ -1147,10 +1202,12 @@ def get_files( name=name, key=key, folder_key=folder_key, folder_path=folder_path ) + MAX_PAGES = 10 skip = 0 top = self._GET_FILES_PAGE_SIZE + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._get_files_spec( bucket.id, prefix=prefix, @@ -1183,11 +1240,23 @@ def get_files( f"Failed to parse file entry: {e}. Item: {item}" ) from e + pages_fetched += 1 + if len(items) < top: break skip += top + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="get_files", + current_skip=skip, + filter_example="file_name_glob='*.pdf'", + ) + @traced(name="buckets_get_files", run_type="uipath") @resource_override(resource_type="bucket") async def get_files_async( @@ -1223,10 +1292,12 @@ async def get_files_async( name=name, key=key, folder_key=folder_key, folder_path=folder_path ) + MAX_PAGES = 10 skip = 0 top = self._GET_FILES_PAGE_SIZE + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._get_files_spec( bucket.id, prefix=prefix, @@ -1261,11 +1332,23 @@ async def get_files_async( f"Failed to parse file entry: {e}. Item: {item}" ) from e + pages_fetched += 1 + if len(items) < top: break skip += top + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="get_files_async", + current_skip=skip, + filter_example="file_name_glob='*.pdf'", + ) + @property def custom_headers(self) -> Dict[str, str]: return self.folder_headers diff --git a/src/uipath/_services/folder_service.py b/src/uipath/_services/folder_service.py index 37c810eaf..873c5ea74 100644 --- a/src/uipath/_services/folder_service.py +++ b/src/uipath/_services/folder_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, AsyncIterator, Dict, Iterator, Optional from typing_extensions import deprecated @@ -7,6 +7,8 @@ from .._config import Config from .._execution_context import ExecutionContext from .._utils import Endpoint, RequestSpec +from ..models.errors import PaginationLimitError +from ..models.folders import Folder from ._base_service import BaseService @@ -21,6 +23,291 @@ class FolderService(BaseService): def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__(config=config, execution_context=execution_context) + @traced(name="folders_list", run_type="uipath") + def list( + self, + *, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[Folder]: + """List folders with auto-pagination. + + Args: + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + Folder: Folder instances + + Examples: + >>> for folder in sdk.folders.list(): + ... print(folder.display_name, folder.fully_qualified_name) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + folder = Folder.model_validate(item) + yield folder + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list", + current_skip=current_skip, + filter_example="ProvisionType eq 'Manual'", + ) + + @traced(name="folders_list", run_type="uipath") + async def list_async( + self, + *, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[Folder]: + """Async version of list().""" + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + ) + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + folder = Folder.model_validate(item) + yield folder + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_async", + current_skip=current_skip, + filter_example="ProvisionType eq 'Manual'", + ) + + @traced(name="folders_retrieve", run_type="uipath") + def retrieve( + self, + *, + key: Optional[str] = None, + display_name: Optional[str] = None, + ) -> Folder: + """Retrieve a folder by key or display name. + + Args: + key: Folder UUID key + display_name: Folder display name + + Returns: + Folder: The folder + + Raises: + LookupError: If the folder is not found + + Examples: + >>> folder = sdk.folders.retrieve(display_name="Shared") + """ + if not key and not display_name: + raise ValueError("Either 'key' or 'display_name' must be provided") + + if key: + for folder in self.list(): + if folder.key == key: + return folder + raise LookupError(f"Folder with key '{key}' not found.") + + spec = self._retrieve_folder_spec( + key=None, + display_name=display_name, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Folder with display_name '{display_name}' not found.") + return Folder.model_validate(items[0]) + + @traced(name="folders_retrieve", run_type="uipath") + async def retrieve_async( + self, + *, + key: Optional[str] = None, + display_name: Optional[str] = None, + ) -> Folder: + """Async version of retrieve().""" + if not key and not display_name: + raise ValueError("Either 'key' or 'display_name' must be provided") + + if key: + async for folder in self.list_async(): + if folder.key == key: + return folder + raise LookupError(f"Folder with key '{key}' not found.") + + spec = self._retrieve_folder_spec( + key=None, + display_name=display_name, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + ) + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Folder with display_name '{display_name}' not found.") + return Folder.model_validate(items[0]) + + @traced(name="folders_retrieve_by_path", run_type="uipath") + def retrieve_by_path(self, folder_path: str) -> Folder: + """Retrieve a folder by its fully qualified path. + + Args: + folder_path: The fully qualified folder path (e.g., 'Shared/Finance') + + Returns: + Folder: The folder + + Raises: + LookupError: If the folder is not found + + Examples: + >>> folder = sdk.folders.retrieve_by_path("Shared/Finance") + """ + spec = self._retrieve_by_path_spec(folder_path=folder_path) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Folder with path '{folder_path}' not found.") + return Folder.model_validate(items[0]) + + @traced(name="folders_retrieve_by_path", run_type="uipath") + async def retrieve_by_path_async(self, folder_path: str) -> Folder: + """Async version of retrieve_by_path().""" + spec = self._retrieve_by_path_spec(folder_path=folder_path) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + ) + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Folder with path '{folder_path}' not found.") + return Folder.model_validate(items[0]) + + @traced(name="folders_exists", run_type="uipath") + def exists( + self, + *, + key: Optional[str] = None, + display_name: Optional[str] = None, + ) -> bool: + """Check if folder exists. + + Args: + key: Folder UUID key + display_name: Folder display name + + Returns: + bool: True if folder exists + + Examples: + >>> if sdk.folders.exists(display_name="Shared"): + ... print("Folder found") + """ + try: + self.retrieve(key=key, display_name=display_name) + return True + except LookupError: + return False + + @traced(name="folders_exists", run_type="uipath") + async def exists_async( + self, + *, + key: Optional[str] = None, + display_name: Optional[str] = None, + ) -> bool: + """Async version of exists().""" + try: + await self.retrieve_async(key=key, display_name=display_name) + return True + except LookupError: + return False + @traced(name="folder_retrieve_key_by_folder_path", run_type="uipath") @deprecated("Use retrieve_key instead") def retrieve_key_by_folder_path(self, folder_path: str) -> Optional[str]: @@ -36,10 +323,12 @@ def retrieve_key(self, *, folder_path: str) -> Optional[str]: Returns: The folder key if found, None otherwise. """ + MAX_PAGES = 50 # Safety limit for search (20 items/page = 1000 items max) skip = 0 take = 20 + pages_fetched = 0 - while True: + while pages_fetched < MAX_PAGES: spec = self._retrieve_spec(folder_path, skip=skip, take=take) response = self.request( spec.method, @@ -47,7 +336,6 @@ def retrieve_key(self, *, folder_path: str) -> Optional[str]: params=spec.params, ).json() - # Search for the folder in current page folder_key = next( ( item["Key"] @@ -61,11 +349,23 @@ def retrieve_key(self, *, folder_path: str) -> Optional[str]: return folder_key page_items = response["PageItems"] + pages_fetched += 1 + if len(page_items) < take: break skip += take + else: + if page_items and len(page_items) == take: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=take, + method_name="retrieve_key", + current_skip=skip, + filter_example=f"folder_path='{folder_path}'", + ) + return None def _retrieve_spec( @@ -83,3 +383,65 @@ def _retrieve_spec( "take": take, }, ) + + def _list_spec( + self, + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing folders.""" + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter: + params["$filter"] = filter + if orderby: + params["$orderby"] = orderby + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Folders"), + params=params, + ) + + def _retrieve_folder_spec( + self, + key: Optional[str], + display_name: Optional[str], + ) -> RequestSpec: + """Build request for retrieving folder by key or display name.""" + filters = [] + if key: + pass + if display_name: + escaped_name = display_name.replace("'", "''") + filters.append(f"DisplayName eq '{escaped_name}'") + + filter_str = " or ".join(filters) if filters else None + + params: Dict[str, Any] = {"$top": 1 if filters else 100} + if filter_str: + params["$filter"] = filter_str + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Folders"), + params=params, + ) + + def _retrieve_by_path_spec( + self, + folder_path: str, + ) -> RequestSpec: + """Build request for retrieving folder by fully qualified path.""" + escaped_path = folder_path.replace("'", "''") + params: Dict[str, Any] = { + "$filter": f"FullyQualifiedName eq '{escaped_path}'", + "$top": 1, + } + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Folders"), + params=params, + ) diff --git a/src/uipath/_services/jobs_service.py b/src/uipath/_services/jobs_service.py index a09fe4e8b..6c76aa74c 100644 --- a/src/uipath/_services/jobs_service.py +++ b/src/uipath/_services/jobs_service.py @@ -3,13 +3,25 @@ import tempfile import uuid from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast, overload +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + 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 .._utils.constants import TEMP_ATTACHMENTS_FOLDER +from ..models.errors import PaginationLimitError +from ..models.exceptions import EnrichedException from ..models.job import Job from ..tracing._traced import traced from ._base_service import BaseService @@ -31,6 +43,287 @@ def __init__(self, config: Config, execution_context: ExecutionContext) -> None: self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) os.makedirs(self._temp_dir, exist_ok=True) + @traced(name="jobs_list", run_type="uipath") + def list( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[Job]: + """List jobs with automatic pagination (limited to 10 pages). + + Args: + folder_path: Folder path to filter jobs + folder_key: Folder key (mutually exclusive with folder_path) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + Job: Job instances + + Raises: + PaginationLimitError: If more than 10 pages (1,000 items) exist. + Use filters or manual pagination to retrieve additional results. + + Note: + Auto-pagination is limited to 10 pages (~1,000 items) to prevent + performance issues with deep OFFSET queries. If you hit this limit: + + 1. Add filters to narrow results: + >>> for job in sdk.jobs.list(filter="State eq 'Successful'"): + ... print(job.key) + + 2. Use manual pagination for large datasets: + >>> skip = 0 + >>> while True: + ... page = list(sdk.jobs.list(skip=skip, top=100)) + ... if not page: + ... break + ... process(page) + ... skip += 100 + + Examples: + >>> # Get up to 1,000 jobs automatically + >>> for job in sdk.jobs.list(): + ... print(job.key, job.state) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + job = Job.model_validate(item) + yield job + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list", + current_skip=current_skip, + filter_example="State eq 'Successful'", + ) + + @traced(name="jobs_list", run_type="uipath") + async def list_async( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[Job]: + """Async version of list() with pagination limit. + + See list() for full documentation. + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + job = Job.model_validate(item) + yield job + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_async", + current_skip=current_skip, + filter_example="State eq 'Successful'", + ) + + @traced(name="jobs_stop", run_type="uipath") + def stop( + self, + *, + job_keys: List[str], + strategy: str = "SoftStop", + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Stop one or more jobs with specified strategy. + + Args: + job_keys: List of job UUID keys to stop + strategy: Stop strategy - "SoftStop" (graceful) or "Kill" (force) + folder_path: Folder path + folder_key: Folder key + + Examples: + >>> # Stop single job by key + >>> sdk.jobs.stop(job_keys=["ee9327fd-237d-419e-86ef-9946b34461e3"]) + + >>> # Stop multiple jobs with Kill strategy + >>> sdk.jobs.stop( + ... job_keys=["uuid-1", "uuid-2"], + ... strategy="Kill" + ... ) + """ + spec = self._stop_spec( + job_keys=job_keys, + strategy=strategy, + folder_key=folder_key, + folder_path=folder_path, + ) + self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + @traced(name="jobs_stop", run_type="uipath") + async def stop_async( + self, + *, + job_keys: List[str], + strategy: str = "SoftStop", + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Async version of stop() - stop one or more jobs with specified strategy. + + Args: + job_keys: List of job UUID keys to stop + strategy: Stop strategy - "SoftStop" (graceful) or "Kill" (force) + folder_path: Folder path + folder_key: Folder key + + Examples: + >>> # Stop single job by key + >>> await sdk.jobs.stop_async(job_keys=["ee9327fd-237d-419e-86ef-9946b34461e3"]) + + >>> # Stop multiple jobs + >>> await sdk.jobs.stop_async(job_keys=["uuid-1", "uuid-2"]) + """ + spec = self._stop_spec( + job_keys=job_keys, + strategy=strategy, + folder_key=folder_key, + folder_path=folder_path, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + @traced(name="jobs_exists", run_type="uipath") + def exists( + self, + job_key: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if job exists. + + Args: + job_key: Job unique identifier + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if job exists + + Examples: + >>> if sdk.jobs.exists(job_key="ee9327fd-237d-419e-86ef-9946b34461e3"): + ... print("Job found") + """ + try: + self.retrieve( + job_key=job_key, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + + @traced(name="jobs_exists", run_type="uipath") + async def exists_async( + self, + job_key: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Async version of exists().""" + try: + await self.retrieve_async( + job_key=job_key, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + @overload def resume(self, *, inbox_id: str, payload: Any) -> None: ... @@ -150,82 +443,77 @@ def custom_headers(self) -> Dict[str, str]: def retrieve( self, - job_key: str, *, + job_key: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Job: - """Retrieve a job identified by its key. + """Retrieve a single job by key. Args: - job_key (str): The job unique identifier. - folder_key (Optional[str]): The key of the folder in which the job was executed. - folder_path (Optional[str]): The path of the folder in which the job was executed. + job_key: Job UUID key + folder_key: The key of the folder in which the job was executed + folder_path: The path of the folder in which the job was executed Returns: - Job: The retrieved job. + Job: The retrieved job Examples: - ```python - from uipath import UiPath - - sdk = UiPath() - job = sdk.jobs.retrieve(job_key="ee9327fd-237d-419e-86ef-9946b34461e3", folder_path="Shared") - ``` + >>> # Retrieve by key + >>> job = sdk.jobs.retrieve(job_key="ee9327fd-237d-419e-86ef-9946b34461e3") """ - spec = self._retrieve_spec( + spec = self._retrieve_by_key_spec( job_key=job_key, folder_key=folder_key, folder_path=folder_path ) - response = self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - return Job.model_validate(response.json()) + try: + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + return Job.model_validate(response.json()) + except EnrichedException as e: + if e.status_code == 404: + raise LookupError(f"Job with key '{job_key}' not found.") from e + raise async def retrieve_async( self, - job_key: str, *, + job_key: str, folder_key: Optional[str] = None, folder_path: Optional[str] = None, ) -> Job: - """Asynchronously retrieve a job identified by its key. + """Asynchronously retrieve a single job by key. Args: - job_key (str): The job unique identifier. - folder_key (Optional[str]): The key of the folder in which the job was executed. - folder_path (Optional[str]): The path of the folder in which the job was executed. + job_key: Job UUID key + folder_key: The key of the folder in which the job was executed + folder_path: The path of the folder in which the job was executed Returns: - Job: The retrieved job. + Job: The retrieved job Examples: - ```python - import asyncio - - from uipath import UiPath - - sdk = UiPath() - - - async def main(): # noqa: D103 - job = await sdk.jobs.retrieve_async(job_key="ee9327fd-237d-419e-86ef-9946b34461e3", folder_path="Shared") - - asyncio.run(main()) - ``` + >>> # Retrieve by key + >>> job = await sdk.jobs.retrieve_async(job_key="ee9327fd-237d-419e-86ef-9946b34461e3") """ - spec = self._retrieve_spec( + spec = self._retrieve_by_key_spec( job_key=job_key, folder_key=folder_key, folder_path=folder_path ) - response = await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - return Job.model_validate(response.json()) + try: + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + return Job.model_validate(response.json()) + except EnrichedException as e: + if e.status_code == 404: + raise LookupError(f"Job with key '{job_key}' not found.") from e + raise def _retrieve_inbox_id( self, @@ -417,13 +705,13 @@ def _resume_spec( }, ) - def _retrieve_spec( + def _retrieve_by_key_spec( self, - *, job_key: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, + folder_key: Optional[str], + folder_path: Optional[str], ) -> RequestSpec: + """Build request for retrieving job by key.""" return RequestSpec( method="GET", endpoint=Endpoint( @@ -920,3 +1208,61 @@ async def main(): # Return only the UUID return attachment_id + + def _list_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing jobs.""" + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter: + params["$filter"] = filter + if orderby: + params["$orderby"] = orderby + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Jobs"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _stop_spec( + self, + job_keys: List[str], + strategy: str, + folder_key: Optional[str], + folder_path: Optional[str], + ) -> RequestSpec: + """Build request for stopping jobs with strategy. + + Note: The UiPath API requires integer job IDs, so we retrieve each job + by key to get its ID. This matches the Swagger StopJobsRequest schema. + """ + job_ids = [] + for job_key in job_keys: + job = self.retrieve( + job_key=job_key, folder_key=folder_key, folder_path=folder_path + ) + job_ids.append(job.id) + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs" + ), + json={ + "jobIds": job_ids, + "strategy": strategy, + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/src/uipath/_services/processes_service.py b/src/uipath/_services/processes_service.py index 95d064fb9..6c8a6aff0 100644 --- a/src/uipath/_services/processes_service.py +++ b/src/uipath/_services/processes_service.py @@ -1,14 +1,16 @@ import json import os import uuid -from typing import Any, Dict, Optional +from typing import Any, AsyncIterator, Dict, Iterator, Optional from .._config import Config from .._execution_context import ExecutionContext from .._folder_context import FolderContext from .._utils import Endpoint, RequestSpec, header_folder, resource_override from .._utils.constants import ENV_JOB_KEY, HEADER_JOB_KEY +from ..models.errors import PaginationLimitError from ..models.job import Job +from ..models.processes import Process from ..tracing._traced import traced from . import AttachmentsService from ._base_service import BaseService @@ -31,6 +33,265 @@ def __init__( self._attachments_service = attachment_service super().__init__(config=config, execution_context=execution_context) + @traced(name="processes_list", run_type="uipath") + def list( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[Process]: + """List processes with auto-pagination. + + Args: + folder_path: Folder path to filter processes + folder_key: Folder key (mutually exclusive with folder_path) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + Process: Process resource instances + + Examples: + >>> for process in sdk.processes.list(): + ... print(process.name, process.version) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + process = Process.model_validate(item) + yield process + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list", + current_skip=current_skip, + filter_example="IsLatestVersion eq true", + ) + + @traced(name="processes_list", run_type="uipath") + async def list_async( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[Process]: + """Async version of list().""" + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + process = Process.model_validate(item) + yield process + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_async", + current_skip=current_skip, + filter_example="IsLatestVersion eq true", + ) + + @traced(name="processes_retrieve", run_type="uipath") + @resource_override(resource_type="process") + def retrieve( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Process: + """Retrieve a process by name or key. + + Args: + name: Process name + key: Process UUID key + folder_path: Folder path + folder_key: Folder UUID key + + Returns: + Process: The process + + Raises: + LookupError: If the process is not found + + Examples: + >>> process = sdk.processes.retrieve(name="MyProcess") + """ + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + spec = self._retrieve_spec( + name=name, + key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Process with name '{name}' or key '{key}' not found.") + return Process.model_validate(items[0]) + + @traced(name="processes_retrieve", run_type="uipath") + @resource_override(resource_type="process") + async def retrieve_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Process: + """Async version of retrieve().""" + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + spec = self._retrieve_spec( + name=name, + key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + raise LookupError(f"Process with name '{name}' or key '{key}' not found.") + return Process.model_validate(items[0]) + + @traced(name="processes_exists", run_type="uipath") + def exists( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if process exists. + + Args: + name: Process name + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if process exists + + Examples: + >>> if sdk.processes.exists("MyProcess"): + ... print("Process found") + """ + try: + self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) + return True + except LookupError: + return False + + @traced(name="processes_exists", run_type="uipath") + async def exists_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Async version of exists().""" + try: + await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + @traced(name="processes_invoke", run_type="uipath") @resource_override(resource_type="process") def invoke( @@ -249,3 +510,59 @@ def _invoke_spec( request_spec.headers[HEADER_JOB_KEY] = job_key return request_spec + + def _list_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing processes.""" + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter: + params["$filter"] = filter + if orderby: + params["$orderby"] = orderby + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Releases"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_spec( + self, + name: Optional[str], + key: Optional[str], + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for retrieving process.""" + filters = [] + if name: + escaped_name = name.replace("'", "''") + filters.append(f"Name eq '{escaped_name}'") + if key: + escaped_key = key.replace("'", "''") + filters.append(f"Key eq '{escaped_key}'") + + filter_str = " or ".join(filters) if filters else None + + params: Dict[str, Any] = {"$top": 1} + if filter_str: + params["$filter"] = filter_str + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Releases"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/src/uipath/_services/queues_service.py b/src/uipath/_services/queues_service.py index 2a6e8fb97..6a5992621 100644 --- a/src/uipath/_services/queues_service.py +++ b/src/uipath/_services/queues_service.py @@ -1,12 +1,20 @@ -from typing import Any, Dict, List, Union +from datetime import datetime +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union from httpx import Response from .._config import Config from .._execution_context import ExecutionContext from .._folder_context import FolderContext -from .._utils import Endpoint, RequestSpec -from ..models import CommitType, QueueItem, TransactionItem, TransactionItemResult +from .._utils import Endpoint, RequestSpec, header_folder, resource_override +from ..models import ( + CommitType, + QueueDefinition, + QueueItem, + TransactionItem, + TransactionItemResult, +) +from ..models.errors import PaginationLimitError from ..tracing._traced import traced from ._base_service import BaseService @@ -21,64 +29,776 @@ class QueuesService(FolderContext, BaseService): def __init__(self, config: Config, execution_context: ExecutionContext) -> None: super().__init__(config=config, execution_context=execution_context) - @traced(name="queues_list_items", run_type="uipath") - def list_items(self) -> Response: - """Retrieves a list of queue items from the Orchestrator. + @traced(name="queues_list_definitions", run_type="uipath") + def list_definitions( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[QueueDefinition]: + """List queue definitions with auto-pagination. + + Args: + folder_path: Folder path to filter queues + folder_key: Folder key (mutually exclusive with folder_path) + name: Filter by queue name (contains match) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + QueueDefinition: Queue definition instances + + Examples: + >>> for queue in sdk.queues.list_definitions(): + ... print(queue.name, queue.max_number_of_retries) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_definitions_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + queue_def = QueueDefinition.model_validate(item) + yield queue_def + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_definitions", + current_skip=current_skip, + filter_example="Name eq 'MyQueue'", + ) + + @traced(name="queues_list_definitions", run_type="uipath") + async def list_definitions_async( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[QueueDefinition]: + """Async version of list_definitions().""" + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_definitions_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + queue_def = QueueDefinition.model_validate(item) + yield queue_def + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_definitions_async", + current_skip=current_skip, + filter_example="Name eq 'MyQueue'", + ) + + @traced(name="queues_retrieve_definition", run_type="uipath") + @resource_override(resource_type="queue_definition") + def retrieve_definition( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueDefinition: + """Retrieve a queue definition by name or key. + + Args: + name: Queue name + key: Queue UUID key + folder_path: Folder path + folder_key: Folder UUID key Returns: - Response: HTTP response containing the list of queue items. + QueueDefinition: The queue definition + + Raises: + LookupError: If the queue is not found + + Examples: + >>> queue = sdk.queues.retrieve_definition(name="InvoiceQueue") """ - spec = self._list_items_spec() - response = self.request(spec.method, url=spec.endpoint) + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") - return response.json() + spec = self._retrieve_definition_spec( + name=name, + key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() - @traced(name="queues_list_items", run_type="uipath") - async def list_items_async(self) -> Response: - """Asynchronously retrieves a list of queue items from the Orchestrator. + items = response.get("value", []) + if not items: + raise LookupError( + f"Queue definition with name '{name}' or key '{key}' not found." + ) + return QueueDefinition.model_validate(items[0]) + + @traced(name="queues_retrieve_definition", run_type="uipath") + @resource_override(resource_type="queue_definition") + async def retrieve_definition_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueDefinition: + """Async version of retrieve_definition().""" + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + spec = self._retrieve_definition_spec( + name=name, + key=key, + folder_path=folder_path, + folder_key=folder_key, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + if not items: + raise LookupError( + f"Queue definition with name '{name}' or key '{key}' not found." + ) + return QueueDefinition.model_validate(items[0]) + + @traced(name="queues_create_definition", run_type="uipath") + def create_definition( + self, + *, + name: str, + description: Optional[str] = None, + max_number_of_retries: int = 0, + accept_automatically_retry: bool = False, + enforce_unique_reference: bool = False, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueDefinition: + """Create a new queue definition. + + Args: + name: Queue name (must be unique within folder) + description: Optional description + max_number_of_retries: Max retry attempts (default 0) + accept_automatically_retry: Auto-retry failed items (default False) + enforce_unique_reference: Enforce unique references (default False) + folder_path: Folder path + folder_key: Folder UUID key Returns: - Response: HTTP response containing the list of queue items. + QueueDefinition: Newly created queue definition + + Examples: + >>> queue = sdk.queues.create_definition( + ... name="InvoiceQueue", + ... max_number_of_retries=3, + ... enforce_unique_reference=True + ... ) """ - spec = self._list_items_spec() - response = await self.request_async(spec.method, url=spec.endpoint) - return response.json() + spec = self._create_definition_spec( + name=name, + description=description, + max_number_of_retries=max_number_of_retries, + accept_automatically_retry=accept_automatically_retry, + enforce_unique_reference=enforce_unique_reference, + folder_path=folder_path, + folder_key=folder_key, + ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + + return QueueDefinition.model_validate(response) + + @traced(name="queues_create_definition", run_type="uipath") + async def create_definition_async( + self, + *, + name: str, + description: Optional[str] = None, + max_number_of_retries: int = 0, + accept_automatically_retry: bool = False, + enforce_unique_reference: bool = False, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueDefinition: + """Async version of create_definition().""" + spec = self._create_definition_spec( + name=name, + description=description, + max_number_of_retries=max_number_of_retries, + accept_automatically_retry=accept_automatically_retry, + enforce_unique_reference=enforce_unique_reference, + folder_path=folder_path, + folder_key=folder_key, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + ).json() + + return QueueDefinition.model_validate(response) + + @traced(name="queues_delete_definition", run_type="uipath") + @resource_override(resource_type="queue_definition") + def delete_definition( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Delete a queue definition. + + Args: + name: Queue name + key: Queue UUID key + folder_path: Folder path + folder_key: Folder UUID key + + Returns: + None + + Examples: + >>> sdk.queues.delete_definition(name="OldQueue") + """ + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + if name and not key: + queue_def = self.retrieve_definition( + name=name, folder_path=folder_path, folder_key=folder_key + ) + if queue_def.id is None: + raise ValueError( + f"Queue definition '{name}' was found, but it does not have an id and cannot be deleted." + ) + key = queue_def.id + + if not isinstance(key, int): + raise ValueError(f"Invalid queue id: {key}") + + spec = self._delete_definition_spec( + queue_id=key, + folder_path=folder_path, + folder_key=folder_key, + ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + @traced(name="queues_delete_definition", run_type="uipath") + @resource_override(resource_type="queue_definition") + async def delete_definition_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Async version of delete_definition().""" + if not name and not key: + raise ValueError("Either 'name' or 'key' must be provided") + + if name and not key: + queue_def = await self.retrieve_definition_async( + name=name, folder_path=folder_path, folder_key=folder_key + ) + if queue_def.id is None: + raise ValueError( + f"Queue definition '{name}' was found, but it does not have an id and cannot be deleted." + ) + key = queue_def.id + + if not isinstance(key, int): + raise ValueError(f"Invalid queue id: {key}") + + spec = self._delete_definition_spec( + queue_id=key, + folder_path=folder_path, + folder_key=folder_key, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + @traced(name="queues_exists_definition", run_type="uipath") + def exists_definition( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if queue definition exists. + + Args: + name: Queue name + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if queue exists + + Examples: + >>> if sdk.queues.exists_definition("InvoiceQueue"): + ... print("Queue found") + """ + try: + self.retrieve_definition( + name=name, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + + @traced(name="queues_exists_definition", run_type="uipath") + async def exists_definition_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Async version of exists_definition().""" + try: + await self.retrieve_definition_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + return True + except LookupError: + return False + + # ========== QUEUE ITEMS ========== + + @traced(name="queues_list_items", run_type="uipath") + def list_items( + self, + *, + queue_name: Optional[str] = None, + queue_key: Optional[str] = None, + status: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> Iterator[QueueItem]: + """List queue items with server-side filtering and auto-pagination. + + Args: + queue_name: Filter by queue name + queue_key: Filter by queue UUID key + status: Filter by status ("New", "InProgress", "Successful", "Failed") + folder_path: Folder path + folder_key: Folder key (mutually exclusive with folder_path) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + QueueItem: Queue item instances + + Examples: + >>> # List all new items in a queue + >>> for item in sdk.queues.list_items(queue_name="InvoiceQueue", status="New"): + ... print(item.reference, item.specific_content) + + >>> # List with custom OData filter + >>> for item in sdk.queues.list_items(filter="Priority eq 'High'"): + ... print(item.reference) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_items_spec( + queue_name=queue_name, + queue_key=queue_key, + status=status, + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + if not items: + break + + for item in items: + yield QueueItem.model_validate(item) + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_items", + current_skip=current_skip, + filter_example="Priority eq 'High'", + ) + + @traced(name="queues_list_items", run_type="uipath") + async def list_items_async( + self, + *, + queue_name: Optional[str] = None, + queue_key: Optional[str] = None, + status: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + top: int = 100, + skip: int = 0, + ) -> AsyncIterator[QueueItem]: + """Async version of list_items() with server-side filtering and auto-pagination. + + Args: + queue_name: Filter by queue name + queue_key: Filter by queue UUID key + status: Filter by status ("New", "InProgress", "Successful", "Failed") + folder_path: Folder path + folder_key: Folder key (mutually exclusive with folder_path) + filter: OData $filter expression + orderby: OData $orderby expression + top: Maximum items per page (default 100) + skip: Number of items to skip + + Yields: + QueueItem: Queue item instances + + Examples: + >>> async for item in sdk.queues.list_items_async(queue_name="InvoiceQueue"): + ... print(item.reference) + """ + MAX_PAGES = 10 + current_skip = skip + pages_fetched = 0 + + while pages_fetched < MAX_PAGES: + spec = self._list_items_spec( + queue_name=queue_name, + queue_key=queue_key, + status=status, + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=current_skip, + top=top, + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + data = response.json() + + items = data.get("value", []) + if not items: + break + + for item in items: + yield QueueItem.model_validate(item) + + pages_fetched += 1 + + if len(items) < top: + break + + current_skip += top + + else: + if items and len(items) == top: + raise PaginationLimitError.create( + max_pages=MAX_PAGES, + items_per_page=top, + method_name="list_items_async", + current_skip=current_skip, + filter_example="Priority eq 'High'", + ) @traced(name="queues_create_item", run_type="uipath") - def create_item(self, item: Union[Dict[str, Any], QueueItem]) -> Response: - """Creates a new queue item in the Orchestrator. + def create_item( + self, + *, + queue_name: Optional[str] = None, + queue_key: Optional[str] = None, + reference: str, + specific_content: Dict[str, Any], + priority: Optional[str] = None, + defer_date: Optional[datetime] = None, + due_date: Optional[datetime] = None, + risk_sla_date: Optional[datetime] = None, + progress: Optional[str] = None, + source: Optional[str] = None, + parent_operation_id: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueItem: + """Creates a new queue item with explicit parameters. Args: - item: Queue item data, either as a dictionary or QueueItem instance. + queue_name: Queue name (alternative to queue_key) + queue_key: Queue UUID key (alternative to queue_name) + reference: Unique reference for tracking (REQUIRED) + specific_content: Queue item data as key-value pairs (REQUIRED) + priority: Processing priority ("Low", "Normal", "High") + defer_date: Earliest date/time for processing + due_date: Latest date/time for processing + risk_sla_date: Risk SLA date/time + progress: Business flow progress tracking + source: Source type of the item + parent_operation_id: Operation ID that started the job + folder_path: Folder path + folder_key: Folder key (mutually exclusive with folder_path) Returns: - Response: HTTP response containing the created queue item details. + QueueItem: The created queue item + + Raises: + ValueError: If neither queue_name nor queue_key is provided + + Example: + >>> item = sdk.queues.create_item( + ... queue_name="InvoiceQueue", + ... reference="INV-001", + ... specific_content={"InvoiceNumber": "INV-001", "Amount": 1000}, + ... priority="High" + ... ) Related Activity: [Add Queue Item](https://docs.uipath.com/ACTIVITIES/other/latest/workflow/add-queue-item) """ - spec = self._create_item_spec(item) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() + if queue_name is None and queue_key is None: + raise ValueError("Either 'queue_name' or 'queue_key' must be provided") + + # Build QueueItem from explicit parameters + # Use model field names for defined fields, aliases for extra fields + item_data: Dict[str, Any] = { + "name": queue_name, + "specific_content": specific_content, + "priority": priority, + "defer_date": defer_date, + "due_date": due_date, + "risk_sla_date": risk_sla_date, + "progress": progress, + "source": source, + "parent_operation_id": parent_operation_id, + "Reference": reference, # Extra field - not defined in model + } + # Remove None values + item_data = {k: v for k, v in item_data.items() if v is not None} + + queue_item = QueueItem(**item_data) + spec = self._create_item_spec( + queue_item, folder_path=folder_path, folder_key=folder_key + ) + response = self.request( + spec.method, url=spec.endpoint, json=spec.json, headers=spec.headers + ) + return QueueItem.model_validate(response.json()) @traced(name="queues_create_item", run_type="uipath") async def create_item_async( - self, item: Union[Dict[str, Any], QueueItem] - ) -> Response: - """Asynchronously creates a new queue item in the Orchestrator. + self, + *, + queue_name: Optional[str] = None, + queue_key: Optional[str] = None, + reference: str, + specific_content: Dict[str, Any], + priority: Optional[str] = None, + defer_date: Optional[datetime] = None, + due_date: Optional[datetime] = None, + risk_sla_date: Optional[datetime] = None, + progress: Optional[str] = None, + source: Optional[str] = None, + parent_operation_id: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> QueueItem: + """Asynchronously creates a new queue item with explicit parameters. Args: - item: Queue item data, either as a dictionary or QueueItem instance. + queue_name: Queue name (alternative to queue_key) + queue_key: Queue UUID key (alternative to queue_name) + reference: Unique reference for tracking (REQUIRED) + specific_content: Queue item data as key-value pairs (REQUIRED) + priority: Processing priority ("Low", "Normal", "High") + defer_date: Earliest date/time for processing + due_date: Latest date/time for processing + risk_sla_date: Risk SLA date/time + progress: Business flow progress tracking + source: Source type of the item + parent_operation_id: Operation ID that started the job + folder_path: Folder path + folder_key: Folder key (mutually exclusive with folder_path) Returns: - Response: HTTP response containing the created queue item details. + QueueItem: The created queue item + + Raises: + ValueError: If neither queue_name nor queue_key is provided + + Example: + >>> item = await sdk.queues.create_item_async( + ... queue_name="InvoiceQueue", + ... reference="INV-001", + ... specific_content={"InvoiceNumber": "INV-001", "Amount": 1000}, + ... priority="High" + ... ) Related Activity: [Add Queue Item](https://docs.uipath.com/ACTIVITIES/other/latest/workflow/add-queue-item) """ - spec = self._create_item_spec(item) + if queue_name is None and queue_key is None: + raise ValueError("Either 'queue_name' or 'queue_key' must be provided") + + item_data: Dict[str, Any] = { + "name": queue_name, + "specific_content": specific_content, + "priority": priority, + "defer_date": defer_date, + "due_date": due_date, + "risk_sla_date": risk_sla_date, + "progress": progress, + "source": source, + "parent_operation_id": parent_operation_id, + "Reference": reference, # Extra field - not defined in model + } + # Remove None values + item_data = {k: v for k, v in item_data.items() if v is not None} + + queue_item = QueueItem(**item_data) + spec = self._create_item_spec( + queue_item, folder_path=folder_path, folder_key=folder_key + ) response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json + spec.method, url=spec.endpoint, json=spec.json, headers=spec.headers ) - return response.json() + return QueueItem.model_validate(response.json()) @traced(name="queues_create_items", run_type="uipath") def create_items( @@ -244,21 +964,59 @@ async def complete_transaction_item_async( def custom_headers(self) -> Dict[str, str]: return self.folder_headers - def _list_items_spec(self) -> RequestSpec: + def _list_items_spec( + self, + queue_name: Optional[str], + queue_key: Optional[str], + status: Optional[str], + folder_path: Optional[str], + folder_key: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing queue items.""" + filters = [] + + if queue_name: + escaped_name = queue_name.replace("'", "''") + filters.append(f"QueueDefinition/Name eq '{escaped_name}'") + + if queue_key: + escaped_key = queue_key.replace("'", "''") + filters.append(f"QueueDefinition/Key eq '{escaped_key}'") + + if status: + filters.append(f"Status eq '{status}'") + + if filter: + filters.append(filter) + + filter_str = " and ".join(filters) if filters else None + + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter_str: + params["$filter"] = filter_str + if orderby: + params["$orderby"] = orderby + return RequestSpec( method="GET", endpoint=Endpoint("/orchestrator_/odata/QueueItems"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, ) - def _create_item_spec(self, item: Union[Dict[str, Any], QueueItem]) -> RequestSpec: - if isinstance(item, dict): - queue_item = QueueItem(**item) - elif isinstance(item, QueueItem): - queue_item = item - - json_payload = { - "itemData": queue_item.model_dump(exclude_unset=True, by_alias=True) - } + def _create_item_spec( + self, + item: QueueItem, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> RequestSpec: + json_payload = {"itemData": item.model_dump(exclude_unset=True, by_alias=True)} return RequestSpec( method="POST", @@ -266,6 +1024,9 @@ def _create_item_spec(self, item: Union[Dict[str, Any], QueueItem]) -> RequestSp "/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" ), json=json_payload, + headers={ + **header_folder(folder_key, folder_path), + }, ) def _create_items_spec( @@ -346,3 +1107,113 @@ def _complete_transaction_item_spec( ) }, ) + + def _list_definitions_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + name: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing queue definitions.""" + filters = [] + if name: + escaped_name = name.replace("'", "''") + filters.append(f"contains(tolower(Name), tolower('{escaped_name}'))") + if filter: + filters.append(filter) + + filter_str = " and ".join(filters) if filters else None + + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter_str: + params["$filter"] = filter_str + if orderby: + params["$orderby"] = orderby + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/QueueDefinitions"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_definition_spec( + self, + name: Optional[str], + key: Optional[str], + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for retrieving queue definition.""" + filters = [] + if name: + escaped_name = name.replace("'", "''") + filters.append(f"Name eq '{escaped_name}'") + if key: + escaped_key = key.replace("'", "''") + filters.append(f"Key eq '{escaped_key}'") + + filter_str = " or ".join(filters) if filters else None + + params: Dict[str, Any] = {"$top": 1} + if filter_str: + params["$filter"] = filter_str + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/QueueDefinitions"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _create_definition_spec( + self, + name: str, + description: Optional[str], + max_number_of_retries: int, + accept_automatically_retry: bool, + enforce_unique_reference: bool, + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for creating queue definition.""" + body = { + "Name": name, + "MaxNumberOfRetries": max_number_of_retries, + "AcceptAutomaticallyRetry": accept_automatically_retry, + "EnforceUniqueReference": enforce_unique_reference, + } + if description: + body["Description"] = description + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/odata/QueueDefinitions"), + json=body, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _delete_definition_spec( + self, + queue_id: int, + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for deleting queue definition by ID.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"/orchestrator_/odata/QueueDefinitions({queue_id})"), + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/src/uipath/models/__init__.py b/src/uipath/models/__init__.py index 3a743427f..55ccc897d 100644 --- a/src/uipath/models/__init__.py +++ b/src/uipath/models/__init__.py @@ -8,6 +8,7 @@ from .context_grounding_index import ContextGroundingIndex from .errors import BaseUrlMissingError, SecretMissingError from .exceptions import IngestionInProgressException +from .folders import Folder from .interrupt_models import ( CreateAction, InvokeProcess, @@ -18,6 +19,7 @@ from .processes import Process from .queues import ( CommitType, + QueueDefinition, QueueItem, QueueItemPriority, TransactionItem, @@ -32,6 +34,7 @@ "ContextGroundingQueryResponse", "ContextGroundingIndex", "Process", + "QueueDefinition", "QueueItem", "CommitType", "TransactionItem", @@ -52,4 +55,5 @@ "SecretMissingError", "Bucket", "BucketFile", + "Folder", ] diff --git a/src/uipath/models/assets.py b/src/uipath/models/assets.py index 52668cc6e..85af4785c 100644 --- a/src/uipath/models/assets.py +++ b/src/uipath/models/assets.py @@ -42,6 +42,29 @@ class UserAsset(BaseModel): ) id: Optional[int] = Field(default=None, alias="Id") + @property + def display_value(self) -> str: + """Safe display value that masks secrets and credentials. + + Returns "***" for Secret and Credential asset types to prevent + accidental exposure in logs or CLI output. + """ + if self.value_type in ("Secret", "Credential"): + return "***" + return str(self.value) if self.value is not None else "None" + + def __repr__(self) -> str: + """Override repr to prevent accidental secret exposure in logs.""" + return ( + f"UserAsset(name={self.name!r}, " + f"value_type={self.value_type!r}, " + f"value={self.display_value!r})" + ) + + def __str__(self) -> str: + """Override str for user-friendly display that masks secrets.""" + return f"UserAsset '{self.name}' ({self.value_type}): {self.display_value}" + class Asset(BaseModel): model_config = ConfigDict( @@ -63,3 +86,26 @@ class Asset(BaseModel): credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") external_name: Optional[str] = Field(default=None, alias="ExternalName") credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") + + @property + def display_value(self) -> str: + """Safe display value that masks secrets and credentials. + + Returns "***" for Secret and Credential asset types to prevent + accidental exposure in logs or CLI output. + """ + if self.value_type in ("Secret", "Credential"): + return "***" + return str(self.value) if self.value is not None else "None" + + def __repr__(self) -> str: + """Override repr to prevent accidental secret exposure in logs.""" + return ( + f"Asset(name={self.name!r}, " + f"value_type={self.value_type!r}, " + f"value={self.display_value!r})" + ) + + def __str__(self) -> str: + """Override str for user-friendly display that masks secrets.""" + return f"Asset '{self.name}' ({self.value_type}): {self.display_value}" diff --git a/src/uipath/models/errors.py b/src/uipath/models/errors.py index e8c188e0e..83b50ce60 100644 --- a/src/uipath/models/errors.py +++ b/src/uipath/models/errors.py @@ -14,3 +14,44 @@ def __init__( ): self.message = message super().__init__(self.message) + + +class PaginationLimitError(Exception): + """Raised when pagination limit is exceeded. + + The SDK limits auto-pagination to prevent performance issues with + deep OFFSET queries. Use filters or manual pagination to retrieve + additional results. + """ + + @staticmethod + def create( + max_pages: int, + items_per_page: int, + method_name: str, + current_skip: int, + filter_example: str, + ) -> "PaginationLimitError": + """Create a PaginationLimitError with a standardized message. + + Args: + max_pages: Maximum number of pages allowed + items_per_page: Number of items per page + method_name: Name of the method that hit the limit + current_skip: Current skip value for manual pagination + filter_example: Example filter expression for the user + + Returns: + PaginationLimitError with formatted message + """ + message = ( + f"Pagination limit reached: {max_pages} pages " + f"({max_pages * items_per_page} items) retrieved. " + f"More results may be available. To retrieve them:\n" + f" 1. Add filters to narrow results: " + f'{method_name}(filter="{filter_example}")\n' + f" 2. Use manual pagination: " + f"{method_name}(skip={current_skip}, top={items_per_page})\n" + f"See: https://docs.uipath.com/orchestrator/automation-cloud/latest/api-guide/building-api-requests" + ) + return PaginationLimitError(message) diff --git a/src/uipath/models/folders.py b/src/uipath/models/folders.py new file mode 100644 index 000000000..cfc32c315 --- /dev/null +++ b/src/uipath/models/folders.py @@ -0,0 +1,39 @@ +"""Folder models for UiPath Orchestrator.""" + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class Folder(BaseModel): + """Folder model for organizational structure.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + id: Optional[int] = Field(None, alias="Id", description="Folder ID") + key: Optional[str] = Field(None, alias="Key", description="Folder UUID key") + display_name: str = Field( + ..., alias="DisplayName", description="Folder display name" + ) + fully_qualified_name: Optional[str] = Field( + None, + alias="FullyQualifiedName", + description="Full path of the folder (e.g., 'Shared/Finance')", + ) + description: Optional[str] = Field( + None, alias="Description", description="Folder description" + ) + parent_id: Optional[int] = Field( + None, alias="ParentId", description="Parent folder ID" + ) + feed_type: Optional[str] = Field( + None, + alias="FeedType", + description="Feed type (e.g., 'Personal', 'DirectoryService')", + ) diff --git a/src/uipath/models/processes.py b/src/uipath/models/processes.py index ace9927d0..5cd2cd966 100644 --- a/src/uipath/models/processes.py +++ b/src/uipath/models/processes.py @@ -30,10 +30,14 @@ class Process(BaseModel): specific_priority_value: int = Field(alias="SpecificPriorityValue") target_framework: str = Field(alias="TargetFramework") id: int = Field(alias="Id") - retention_action: str = Field(alias="RetentionAction") - retention_period: int = Field(alias="RetentionPeriod") - stale_retention_action: str = Field(alias="StaleRetentionAction") - stale_retention_period: int = Field(alias="StaleRetentionPeriod") + retention_action: Optional[str] = Field(default=None, alias="RetentionAction") + retention_period: Optional[int] = Field(default=None, alias="RetentionPeriod", ge=0) + stale_retention_action: Optional[str] = Field( + default=None, alias="StaleRetentionAction" + ) + stale_retention_period: Optional[int] = Field( + default=None, alias="StaleRetentionPeriod", ge=0 + ) arguments: Optional[Dict[str, Optional[Any]]] = Field( default=None, alias="Arguments" ) diff --git a/src/uipath/models/queues.py b/src/uipath/models/queues.py index 3549e741e..80f1045de 100644 --- a/src/uipath/models/queues.py +++ b/src/uipath/models/queues.py @@ -33,7 +33,8 @@ def serialize_datetime(self, value): return value.isoformat() if value else None return value - name: str = Field( + name: Optional[str] = Field( + default=None, description="The name of the queue into which the item will be added.", alias="Name", ) @@ -180,3 +181,24 @@ def serialize_datetime(self, value): description="The operation id which finished the queue item. Will be saved only if queue item is in final state", alias="OperationId", ) + + +class QueueDefinition(BaseModel): + """Queue definition model.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + id: Optional[int] = Field(None, alias="Id") + key: Optional[str] = Field(None, alias="Key") + name: str = Field(..., alias="Name") + description: Optional[str] = Field(None, alias="Description") + max_number_of_retries: int = Field(0, alias="MaxNumberOfRetries") + accept_automatically_retry: bool = Field(False, alias="AcceptAutomaticallyRetry") + enforce_unique_reference: bool = Field(False, alias="EnforceUniqueReference") + creation_time: Optional[datetime] = Field(None, alias="CreationTime") diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index 31c9744ad..77b28d5af 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -94,7 +94,7 @@ async def test_read_job_trigger_successful( result = await HitlReader.read(resume_trigger) assert result == output_args mock_job_retrieve_async.assert_called_once_with( - job_key, folder_key="test-folder", folder_path="test-path" + job_key=job_key, folder_key="test-folder", folder_path="test-path" ) @pytest.mark.anyio @@ -128,7 +128,7 @@ async def test_read_job_trigger_failed( assert error_dict["title"] == "Invoked process did not finish successfully." assert job_error_info.code in error_dict["detail"] mock_job_retrieve_async.assert_called_once_with( - job_key, folder_key="test-folder", folder_path="test-path" + job_key=job_key, folder_key="test-folder", folder_path="test-path" ) @pytest.mark.anyio diff --git a/tests/models/test_process_model.py b/tests/models/test_process_model.py new file mode 100644 index 000000000..aeeaa130a --- /dev/null +++ b/tests/models/test_process_model.py @@ -0,0 +1,505 @@ +"""Tests for Process model validation - retention fields optional.""" + +import pytest +from pydantic import ValidationError + +from uipath.models.processes import Process + + +class TestProcessModelRetentionFields: + """Test Process model handles optional retention fields correctly.""" + + def test_process_with_all_retention_fields(self) -> None: + """Test Process validates with all retention fields present.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "RetentionAction": "Delete", + "RetentionPeriod": 30, + "StaleRetentionAction": "Archive", + "StaleRetentionPeriod": 90, + "Tags": [], + } + + process = Process.model_validate(data) + + assert process.retention_action == "Delete" + assert process.retention_period == 30 + assert process.stale_retention_action == "Archive" + assert process.stale_retention_period == 90 + + def test_process_without_retention_fields(self) -> None: + """Test Process validates WITHOUT retention fields (defaults to None).""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + # No retention fields + } + + process = Process.model_validate(data) + + # Should default to None + assert process.retention_action is None + assert process.retention_period is None + assert process.stale_retention_action is None + assert process.stale_retention_period is None + + def test_process_with_partial_retention_fields(self) -> None: + """Test Process validates with only some retention fields.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + # Only retention_action, not retention_period + "RetentionAction": "Delete", + } + + process = Process.model_validate(data) + + assert process.retention_action == "Delete" + assert process.retention_period is None # Missing field defaults to None + + def test_process_with_null_retention_fields(self) -> None: + """Test Process validates with explicitly null retention fields.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + # Explicitly null + "RetentionAction": None, + "RetentionPeriod": None, + "StaleRetentionAction": None, + "StaleRetentionPeriod": None, + } + + process = Process.model_validate(data) + + assert process.retention_action is None + assert process.retention_period is None + assert process.stale_retention_action is None + assert process.stale_retention_period is None + + @pytest.mark.parametrize( + "retention_action,retention_period", + [ + ("Delete", 30), + ("Archive", 60), + (None, None), + ("Delete", None), # Action without period + (None, 30), # Period without action (weird but valid) + ], + ) + def test_process_retention_combinations( + self, retention_action: str | None, retention_period: int | None + ) -> None: + """Test Process validates with various retention field combinations.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + } + + # Add retention fields if not None + if retention_action is not None: + data["RetentionAction"] = retention_action + if retention_period is not None: + data["RetentionPeriod"] = retention_period + + process = Process.model_validate(data) + + assert process.retention_action == retention_action + assert process.retention_period == retention_period + + @pytest.mark.parametrize( + "stale_retention_action,stale_retention_period", + [ + ("Delete", 30), + ("Archive", 90), + (None, None), + ("Archive", None), # Action without period + (None, 60), # Period without action + ], + ) + def test_process_stale_retention_combinations( + self, stale_retention_action: str | None, stale_retention_period: int | None + ) -> None: + """Test Process validates with various stale retention field combinations.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + } + + # Add stale retention fields if not None + if stale_retention_action is not None: + data["StaleRetentionAction"] = stale_retention_action + if stale_retention_period is not None: + data["StaleRetentionPeriod"] = stale_retention_period + + process = Process.model_validate(data) + + assert process.stale_retention_action == stale_retention_action + assert process.stale_retention_period == stale_retention_period + + def test_process_all_retention_combinations(self) -> None: + """Test Process with all four retention fields in various states.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionAction": "Delete", + "RetentionPeriod": 30, + "StaleRetentionAction": None, # Mixed: regular set, stale null + "StaleRetentionPeriod": None, + } + + process = Process.model_validate(data) + + assert process.retention_action == "Delete" + assert process.retention_period == 30 + assert process.stale_retention_action is None + assert process.stale_retention_period is None + + def test_process_field_alias_mapping(self) -> None: + """Test Process correctly maps PascalCase API fields to snake_case.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + # API uses PascalCase + "RetentionAction": "Delete", + "RetentionPeriod": 30, + } + + process = Process.model_validate(data) + + # Python uses snake_case + assert hasattr(process, "retention_action") + assert hasattr(process, "retention_period") + assert process.retention_action == "Delete" + assert process.retention_period == 30 + + def test_process_zero_retention_period(self) -> None: + """Test Process handles zero retention period (edge case).""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionPeriod": 0, # Zero is valid but unusual + } + + process = Process.model_validate(data) + + assert process.retention_period == 0 + + def test_process_negative_retention_period_fails(self) -> None: + """Test Process rejects negative retention period.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionPeriod": -1, # Negative should fail + } + + # Model should reject negative retention period + with pytest.raises(ValidationError) as exc_info: + Process.model_validate(data) + + # Verify the error is about the RetentionPeriod field + error_message = str(exc_info.value).lower() + assert "retentionperiod" in error_message + assert "greater than or equal to 0" in error_message + + def test_process_negative_stale_retention_period_fails(self) -> None: + """Test Process rejects negative stale retention period.""" + data = { + "Key": "process-key-124", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "StaleRetentionPeriod": -5, # Negative should fail + } + + # Model should reject negative stale retention period + with pytest.raises(ValidationError) as exc_info: + Process.model_validate(data) + + # Verify the error is about the StaleRetentionPeriod field + error_message = str(exc_info.value).lower() + assert "staleretentionperiod" in error_message + assert "greater than or equal to 0" in error_message + + def test_process_large_retention_period(self) -> None: + """Test Process handles very large retention period values.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionPeriod": 3650, # 10 years in days + "StaleRetentionPeriod": 7300, # 20 years + } + + process = Process.model_validate(data) + + assert process.retention_period == 3650 + assert process.stale_retention_period == 7300 + + @pytest.mark.parametrize("action", ["Delete", "Archive", "Keep"]) + def test_process_retention_action_case_sensitive(self, action: str) -> None: + """Test Process accepts various retention action values.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionAction": action, + } + + process = Process.model_validate(data) + assert process.retention_action == action + + def test_process_model_serialization_preserves_retention(self) -> None: + """Test Process serialization includes retention fields.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + "RetentionAction": "Delete", + "RetentionPeriod": 30, + } + + process = Process.model_validate(data) + serialized = process.model_dump(by_alias=True) + + # Should use API field names (PascalCase) + assert "RetentionAction" in serialized + assert "RetentionPeriod" in serialized + assert serialized["RetentionAction"] == "Delete" + assert serialized["RetentionPeriod"] == 30 + + def test_process_none_serialization(self) -> None: + """Test Process serialization excludes None retention fields.""" + data = { + "Key": "process-key-123", + "ProcessKey": "proc-key", + "ProcessVersion": "1.0.0", + "IsLatestVersion": True, + "IsProcessDeleted": False, + "Description": "Test process", + "Name": "TestProcess", + "ProcessType": "Process", + "RequiresUserInteraction": False, + "IsAttended": False, + "IsCompiled": True, + "FeedId": "feed-123", + "JobPriority": "Normal", + "SpecificPriorityValue": 5000, + "TargetFramework": "Windows", + "Id": 1, + "Tags": [], + # No retention fields + } + + process = Process.model_validate(data) + serialized = process.model_dump(by_alias=True, exclude_none=True) + + # Should not include None fields when exclude_none=True + assert "RetentionAction" not in serialized + assert "RetentionPeriod" not in serialized + assert "StaleRetentionAction" not in serialized + assert "StaleRetentionPeriod" not in serialized diff --git a/tests/sdk/services/conftest.py b/tests/sdk/services/conftest.py index 1d73cc366..e74a067c9 100644 --- a/tests/sdk/services/conftest.py +++ b/tests/sdk/services/conftest.py @@ -67,3 +67,39 @@ def wrapper(*args, **kwargs): TracingManager.reapply_traced_decorator(mock_tracer_impl) yield TracingManager.reapply_traced_decorator(None) + + +@pytest.fixture +def attachments_service(config: Config, execution_context: ExecutionContext): + from uipath._services.attachments_service import AttachmentsService + + return AttachmentsService(config=config, execution_context=execution_context) + + +@pytest.fixture +def processes_service( + config: Config, + execution_context: ExecutionContext, + attachments_service, +): + from uipath._services.processes_service import ProcessesService + + return ProcessesService( + config=config, + execution_context=execution_context, + attachment_service=attachments_service, + ) + + +@pytest.fixture +def queues_service(config: Config, execution_context: ExecutionContext): + from uipath._services.queues_service import QueuesService + + return QueuesService(config=config, execution_context=execution_context) + + +@pytest.fixture +def jobs_service(config: Config, execution_context: ExecutionContext): + from uipath._services.jobs_service import JobsService + + return JobsService(config=config, execution_context=execution_context) diff --git a/tests/sdk/services/test_assets_service_field_mapping.py b/tests/sdk/services/test_assets_service_field_mapping.py new file mode 100644 index 000000000..67d9f6433 --- /dev/null +++ b/tests/sdk/services/test_assets_service_field_mapping.py @@ -0,0 +1,808 @@ +"""Tests for Assets Service field mapping - StringValue/IntValue/BoolValue.""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._config import Config +from uipath._execution_context import ExecutionContext +from uipath._services.assets_service import AssetsService + + +@pytest.fixture +def assets_service( + config: Config, execution_context: ExecutionContext +) -> AssetsService: + """AssetsService fixture for testing.""" + return AssetsService(config=config, execution_context=execution_context) + + +class TestAssetsServiceFieldMapping: + """Test asset creation sends correct field names to API.""" + + def test_create_text_asset_uses_string_value( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset sends 'StringValue' field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test-value", + "ValueType": "Text", + } + ) + + assets_service.create(name="TestAsset", value="test-value", value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + + # Verify request body uses "StringValue", not "Value" + body = json.loads(request.content) + assert "StringValue" in body + assert body["StringValue"] == "test-value" + assert "Value" not in body # Should NOT use generic "Value" + + def test_create_integer_asset_uses_int_value( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset sends 'IntValue' field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "PortAsset", + "IntValue": 8080, + "ValueType": "Integer", + } + ) + + assets_service.create(name="PortAsset", value=8080, value_type="Integer") + + request = httpx_mock.get_request() + assert request is not None + + body = json.loads(request.content) + assert "IntValue" in body + assert body["IntValue"] == 8080 + assert "Value" not in body + + def test_create_boolean_asset_uses_bool_value( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Boolean asset sends 'BoolValue' field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "EnabledAsset", + "BoolValue": True, + "ValueType": "Boolean", + } + ) + + assets_service.create(name="EnabledAsset", value=True, value_type="Boolean") + + request = httpx_mock.get_request() + assert request is not None + + body = json.loads(request.content) + assert "BoolValue" in body + assert body["BoolValue"] is True + assert "Value" not in body + + def test_create_text_asset_with_empty_string( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles empty string.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "EmptyAsset", + "StringValue": "", + "ValueType": "Text", + } + ) + + assets_service.create(name="EmptyAsset", value="", value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == "" + + def test_create_integer_asset_with_zero( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset handles zero value.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "ZeroAsset", + "IntValue": 0, + "ValueType": "Integer", + } + ) + + assets_service.create(name="ZeroAsset", value=0, value_type="Integer") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["IntValue"] == 0 + + def test_create_boolean_asset_false( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Boolean asset handles False.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "DisabledAsset", + "BoolValue": False, + "ValueType": "Boolean", + } + ) + + assets_service.create(name="DisabledAsset", value=False, value_type="Boolean") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["BoolValue"] is False + + def test_create_text_asset_with_unicode( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles Unicode characters.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "UnicodeAsset", + "StringValue": "Hello 世界 🌍", + "ValueType": "Text", + } + ) + + assets_service.create( + name="UnicodeAsset", value="Hello 世界 🌍", value_type="Text" + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == "Hello 世界 🌍" + + def test_create_integer_asset_with_negative( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset handles negative values.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "NegativeAsset", + "IntValue": -42, + "ValueType": "Integer", + } + ) + + assets_service.create(name="NegativeAsset", value=-42, value_type="Integer") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["IntValue"] == -42 + + def test_create_text_asset_with_special_chars( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles special characters.""" + special_value = 'Test "quotes" and \\backslashes\\ and\nnewlines' + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "SpecialAsset", + "StringValue": special_value, + "ValueType": "Text", + } + ) + + assets_service.create( + name="SpecialAsset", value=special_value, value_type="Text" + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == special_value + + def test_create_integer_asset_with_large_number( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset handles large numbers.""" + large_number = 2147483647 # Max 32-bit int + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "LargeAsset", + "IntValue": large_number, + "ValueType": "Integer", + } + ) + + assets_service.create( + name="LargeAsset", value=large_number, value_type="Integer" + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["IntValue"] == large_number + + def test_create_asset_sets_value_type_field( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() sets ValueType field in request.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test", + "ValueType": "Text", + } + ) + + assets_service.create(name="TestAsset", value="test", value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "ValueType" in body + assert body["ValueType"] == "Text" + + def test_create_asset_sets_name_field( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() sets Name field in request.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "MyAsset", + "StringValue": "test", + "ValueType": "Text", + } + ) + + assets_service.create(name="MyAsset", value="test", value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "Name" in body + assert body["Name"] == "MyAsset" + + @pytest.mark.parametrize( + "value_type,value,expected_field", + [ + ("Text", "hello", "StringValue"), + ("Integer", 42, "IntValue"), + ("Boolean", True, "BoolValue"), + ], + ) + def test_create_asset_field_mapping_parametrized( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + value_type: str, + value: str | int | bool, + expected_field: str, + ) -> None: + """Test create() uses correct field for each value type.""" + response_data: dict[str, str | int | bool] = { + "Key": "asset-key-123", + "Name": "TestAsset", + "ValueType": value_type, + } + response_data[expected_field] = value + + httpx_mock.add_response(json=response_data) + + assets_service.create(name="TestAsset", value=value, value_type=value_type) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert expected_field in body + assert body[expected_field] == value + + def test_create_text_asset_long_string( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles very long strings.""" + long_string = "a" * 10000 + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "LongAsset", + "StringValue": long_string, + "ValueType": "Text", + } + ) + + assets_service.create(name="LongAsset", value=long_string, value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == long_string + assert len(body["StringValue"]) == 10000 + + def test_create_text_asset_multiline( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles multiline strings.""" + multiline = "Line 1\nLine 2\nLine 3" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "MultilineAsset", + "StringValue": multiline, + "ValueType": "Text", + } + ) + + assets_service.create(name="MultilineAsset", value=multiline, value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == multiline + + def test_create_asset_response_model_validation( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() validates response and returns Asset model.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test-value", + "ValueType": "Text", + } + ) + + asset = assets_service.create( + name="TestAsset", value="test-value", value_type="Text" + ) + + # Verify returned model + assert asset.key == "asset-key-123" + assert asset.name == "TestAsset" + assert asset.value_type == "Text" + + def test_create_asset_api_endpoint_format( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() sends request to correct OData endpoint.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test", + "ValueType": "Text", + } + ) + + assets_service.create(name="TestAsset", value="test", value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + assert "/orchestrator_/odata/Assets" in str(request.url) + assert request.method == "POST" + + def test_create_asset_with_folder_path_header( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() sends folder path header when provided.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test", + "ValueType": "Text", + } + ) + + assets_service.create( + name="TestAsset", + value="test", + value_type="Text", + folder_path="Shared/Finance", + ) + + request = httpx_mock.get_request() + # Note: folder headers are handled by base service + # Just verify request was made successfully + assert request is not None + + def test_create_asset_with_folder_key_header( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() sends folder key header when provided.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": "test", + "ValueType": "Text", + } + ) + + assets_service.create( + name="TestAsset", + value="test", + value_type="Text", + folder_key="folder-key-123", + ) + + request = httpx_mock.get_request() + # Note: folder headers are handled by base service + assert request is not None + + def test_create_integer_asset_with_min_int( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset handles minimum integer value.""" + min_int = -2147483648 # Min 32-bit signed int + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "MinIntAsset", + "IntValue": min_int, + "ValueType": "Integer", + } + ) + + assets_service.create(name="MinIntAsset", value=min_int, value_type="Integer") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["IntValue"] == min_int + + def test_create_text_asset_with_tabs( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles tab characters.""" + text_with_tabs = "Column1\tColumn2\tColumn3" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TabAsset", + "StringValue": text_with_tabs, + "ValueType": "Text", + } + ) + + assets_service.create(name="TabAsset", value=text_with_tabs, value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == text_with_tabs + assert "\t" in body["StringValue"] + + def test_create_text_asset_with_crlf( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles CRLF line endings.""" + text_with_crlf = "Line1\r\nLine2\r\nLine3" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "CRLFAsset", + "StringValue": text_with_crlf, + "ValueType": "Text", + } + ) + + assets_service.create(name="CRLFAsset", value=text_with_crlf, value_type="Text") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["StringValue"] == text_with_crlf + + @pytest.mark.asyncio + async def test_create_async_text_asset( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create_async() for Text asset uses StringValue field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-async", + "Name": "AsyncTextAsset", + "StringValue": "async-text-value", + "ValueType": "Text", + } + ) + + asset = await assets_service.create_async( + name="AsyncTextAsset", value="async-text-value", value_type="Text" + ) + + assert asset.key == "asset-key-async" + assert asset.string_value == "async-text-value" + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "StringValue" in body + assert body["StringValue"] == "async-text-value" + + @pytest.mark.asyncio + async def test_create_async_integer_asset( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create_async() for Integer asset uses IntValue field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-async", + "Name": "AsyncIntAsset", + "IntValue": 9999, + "ValueType": "Integer", + } + ) + + asset = await assets_service.create_async( + name="AsyncIntAsset", value=9999, value_type="Integer" + ) + + assert asset.key == "asset-key-async" + assert asset.int_value == 9999 + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "IntValue" in body + assert body["IntValue"] == 9999 + + @pytest.mark.asyncio + async def test_create_async_boolean_asset( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create_async() for Boolean asset uses BoolValue field.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-async", + "Name": "AsyncBoolAsset", + "BoolValue": False, + "ValueType": "Boolean", + } + ) + + asset = await assets_service.create_async( + name="AsyncBoolAsset", value=False, value_type="Boolean" + ) + + assert asset.key == "asset-key-async" + assert asset.bool_value is False + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "BoolValue" in body + assert body["BoolValue"] is False + + def test_create_integer_asset_with_max_int64( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Integer asset handles 64-bit max value.""" + max_int64 = 9223372036854775807 # Max 64-bit signed int + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "MaxInt64Asset", + "IntValue": max_int64, + "ValueType": "Integer", + } + ) + + assets_service.create( + name="MaxInt64Asset", value=max_int64, value_type="Integer" + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "IntValue" in body + assert body["IntValue"] == max_int64 + + def test_create_text_asset_with_mixed_whitespace( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Text asset handles mixed whitespace.""" + mixed_whitespace = " \t Leading\n\tMixed\r\n Trailing \t\n" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "WhitespaceAsset", + "StringValue": mixed_whitespace, + "ValueType": "Text", + } + ) + + assets_service.create( + name="WhitespaceAsset", value=mixed_whitespace, value_type="Text" + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "StringValue" in body + assert body["StringValue"] == mixed_whitespace + + def test_create_boolean_asset_with_value_scope( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test create() for Boolean asset includes value scope.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "ScopedBoolAsset", + "BoolValue": True, + "ValueType": "Boolean", + "ValueScope": "Global", + } + ) + + asset = assets_service.create( + name="ScopedBoolAsset", value=True, value_type="Boolean" + ) + + assert asset.key == "asset-key-123" + assert asset.bool_value is True + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert "BoolValue" in body + assert body["BoolValue"] is True diff --git a/tests/sdk/services/test_exception_handling_improvements.py b/tests/sdk/services/test_exception_handling_improvements.py new file mode 100644 index 000000000..20f20afac --- /dev/null +++ b/tests/sdk/services/test_exception_handling_improvements.py @@ -0,0 +1,647 @@ +"""Tests for exception handling improvements (EnrichedException changes). + +This test module verifies the changes made to exception handling across: +- jobs_service.py: retrieve() and retrieve_async() now catch EnrichedException with status_code +- attachments_service.py: download/delete methods now catch EnrichedException with status_code + +Changes: Replaced string matching "404" in str(e) with proper exception type checking. +""" + +import uuid +from pathlib import Path + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._config import Config +from uipath._execution_context import ExecutionContext +from uipath._services.attachments_service import AttachmentsService +from uipath._services.jobs_service import JobsService +from uipath.models.exceptions import EnrichedException + + +@pytest.fixture +def jobs_service(config: Config, execution_context: ExecutionContext) -> JobsService: + """JobsService fixture for testing.""" + return JobsService(config=config, execution_context=execution_context) + + +@pytest.fixture +def attachments_service( + config: Config, execution_context: ExecutionContext +) -> AttachmentsService: + """AttachmentsService fixture for testing.""" + return AttachmentsService(config=config, execution_context=execution_context) + + +class TestJobsServiceExceptionHandling: + """Test exception handling improvements in JobsService.""" + + def test_retrieve_catches_enriched_exception_404( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() catches EnrichedException with status_code=404 and raises LookupError.""" + job_key = "nonexistent-job-key" + + # Mock 404 response + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=404, + json={"message": "Job not found", "errorCode": 1001}, + ) + + # Should raise LookupError (converted from EnrichedException with 404) + with pytest.raises(LookupError, match=f"Job with key '{job_key}' not found"): + jobs_service.retrieve(job_key=job_key) + + def test_retrieve_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() propagates non-404 errors (500, 401, 403) without converting to LookupError.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + job_key = f"job-{status_code}" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + # Should raise EnrichedException, not LookupError + with pytest.raises(EnrichedException) as exc_info: + jobs_service.retrieve(job_key=job_key) + + # Verify status_code attribute + assert exc_info.value.status_code == status_code + + def test_retrieve_exception_chaining_preserved( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() preserves exception chain with 'from e' syntax.""" + job_key = "nonexistent-job" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=404, + json={"message": "Not found"}, + ) + + try: + jobs_service.retrieve(job_key=job_key) + except LookupError as e: + # Verify exception chaining (from e) + assert e.__cause__ is not None + assert isinstance(e.__cause__, EnrichedException) + assert e.__cause__.status_code == 404 + + @pytest.mark.asyncio + async def test_retrieve_async_catches_enriched_exception_404( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_async() catches EnrichedException with status_code=404.""" + job_key = "nonexistent-async-job" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=404, + json={"message": "Job not found"}, + ) + + with pytest.raises(LookupError, match=f"Job with key '{job_key}' not found"): + await jobs_service.retrieve_async(job_key=job_key) + + @pytest.mark.asyncio + async def test_retrieve_async_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_async() propagates non-404 errors.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + job_key = f"job-async-{status_code}" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + with pytest.raises(EnrichedException) as exc_info: + await jobs_service.retrieve_async(job_key=job_key) + + assert exc_info.value.status_code == status_code + + def test_exists_returns_false_for_404( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists() returns False when retrieve() raises LookupError from 404.""" + job_key = "nonexistent-job" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=404, + json={"message": "Job not found"}, + ) + + # Should return False, not raise exception + assert jobs_service.exists(job_key) is False + + def test_exists_propagates_500_errors( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists() propagates 500 errors instead of returning False.""" + job_key = "error-job" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=500, + json={"error": "Internal Server Error"}, + ) + + # Should raise EnrichedException, not return False + with pytest.raises(EnrichedException) as exc_info: + jobs_service.exists(job_key) + + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio + async def test_exists_async_returns_false_for_404( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists_async() returns False for 404.""" + job_key = "nonexistent-async-job" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=404, + json={"message": "Job not found"}, + ) + + assert await jobs_service.exists_async(job_key) is False + + @pytest.mark.parametrize( + "status_code,error_msg", + [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ], + ) + def test_exception_parity_sync_async( + self, + httpx_mock: HTTPXMock, + jobs_service: JobsService, + base_url: str, + org: str, + tenant: str, + status_code: int, + error_msg: str, + ) -> None: + """Test sync and async raise identical exceptions for non-404 errors.""" + job_key = f"parity-job-{status_code}" + + # Mock for sync call + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + # Sync should raise EnrichedException + with pytest.raises(EnrichedException) as sync_exc: + jobs_service.retrieve(job_key=job_key) + + assert sync_exc.value.status_code == status_code + assert error_msg in str(sync_exc.value) + + +class TestAttachmentsServiceExceptionHandling: + """Test exception handling improvements in AttachmentsService.""" + + def test_download_catches_enriched_exception_404_with_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download() catches EnrichedException 404 and falls back to local storage.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Create local file in temp directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + local_file = temp_dir / f"{attachment_key}_test_document.pdf" + local_file.write_text("test content") + + # Override temp directory + attachments_service._temp_dir = str(temp_dir) + + # Download should succeed by falling back to local file + destination = tmp_path / "downloaded.pdf" + result = attachments_service.download( + key=attachment_key, destination_path=str(destination) + ) + + # Verify local file was copied and name returned + assert result == "test_document.pdf" + assert destination.exists() + assert destination.read_text() == "test content" + + def test_download_catches_enriched_exception_404_without_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download() raises Exception when 404 and no local file exists.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Override temp directory to empty directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + attachments_service._temp_dir = str(temp_dir) + + # Download should raise Exception (converted from EnrichedException) + destination = tmp_path / "downloaded.pdf" + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_key} not found in UiPath or local storage", + ): + attachments_service.download( + key=attachment_key, destination_path=str(destination) + ) + + def test_download_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download() propagates non-404 errors (500, 401, 403) without converting.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + attachment_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + # Should raise EnrichedException, not generic Exception + destination = tmp_path / f"download_{status_code}.pdf" + with pytest.raises(EnrichedException) as exc_info: + attachments_service.download( + key=attachment_key, destination_path=str(destination) + ) + + # Verify status_code attribute + assert exc_info.value.status_code == status_code + + def test_download_exception_chaining_preserved( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download() preserves exception chain with 'from e' syntax.""" + attachment_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Not found"}, + ) + + # Override temp directory to empty directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + attachments_service._temp_dir = str(temp_dir) + + try: + destination = tmp_path / "downloaded.pdf" + attachments_service.download( + key=attachment_key, destination_path=str(destination) + ) + except Exception as e: + # Verify exception chaining (from e) + assert e.__cause__ is not None + assert isinstance(e.__cause__, EnrichedException) + assert e.__cause__.status_code == 404 + + @pytest.mark.asyncio + async def test_download_async_catches_enriched_exception_404_with_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download_async() catches EnrichedException 404 and falls back to local storage.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Create local file in temp directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + local_file = temp_dir / f"{attachment_key}_async_document.pdf" + local_file.write_text("async test content") + + # Override temp directory + attachments_service._temp_dir = str(temp_dir) + + # Download should succeed by falling back to local file + destination = tmp_path / "downloaded_async.pdf" + result = await attachments_service.download_async( + key=attachment_key, destination_path=str(destination) + ) + + # Verify local file was copied and name returned + assert result == "async_document.pdf" + assert destination.exists() + assert destination.read_text() == "async test content" + + @pytest.mark.asyncio + async def test_download_async_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test download_async() propagates non-404 errors.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + attachment_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + destination = tmp_path / f"download_async_{status_code}.pdf" + with pytest.raises(EnrichedException) as exc_info: + await attachments_service.download_async( + key=attachment_key, destination_path=str(destination) + ) + + assert exc_info.value.status_code == status_code + + def test_delete_catches_enriched_exception_404_with_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test delete() catches EnrichedException 404 and deletes from local storage.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Create local file in temp directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + local_file = temp_dir / f"{attachment_key}_to_delete.pdf" + local_file.write_text("content to delete") + + # Override temp directory + attachments_service._temp_dir = str(temp_dir) + + # Delete should succeed by deleting local file + attachments_service.delete(key=attachment_key) + + # Verify local file was deleted + assert not local_file.exists() + + def test_delete_catches_enriched_exception_404_without_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test delete() raises Exception when 404 and no local file exists.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Override temp directory to empty directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + attachments_service._temp_dir = str(temp_dir) + + # Delete should raise Exception + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_key} not found in UiPath or local storage", + ): + attachments_service.delete(key=attachment_key) + + def test_delete_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test delete() propagates non-404 errors (500, 401, 403) without converting.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + attachment_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + # Should raise EnrichedException, not generic Exception + with pytest.raises(EnrichedException) as exc_info: + attachments_service.delete(key=attachment_key) + + # Verify status_code attribute + assert exc_info.value.status_code == status_code + + @pytest.mark.asyncio + async def test_delete_async_catches_enriched_exception_404_with_local_file( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ) -> None: + """Test delete_async() catches EnrichedException 404 and deletes from local storage.""" + attachment_key = uuid.uuid4() + + # Mock 404 response from API + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=404, + json={"message": "Attachment not found"}, + ) + + # Create local file in temp directory + temp_dir = tmp_path / "temp_attachments" + temp_dir.mkdir() + local_file = temp_dir / f"{attachment_key}_async_to_delete.pdf" + local_file.write_text("async content to delete") + + # Override temp directory + attachments_service._temp_dir = str(temp_dir) + + # Delete should succeed by deleting local file + await attachments_service.delete_async(key=attachment_key) + + # Verify local file was deleted + assert not local_file.exists() + + @pytest.mark.asyncio + async def test_delete_async_propagates_non_404_errors( + self, + httpx_mock: HTTPXMock, + attachments_service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test delete_async() propagates non-404 errors.""" + test_cases = [ + (500, "Internal Server Error"), + (401, "Unauthorized"), + (403, "Forbidden"), + ] + + for status_code, error_msg in test_cases: + attachment_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + status_code=status_code, + json={"error": error_msg}, + ) + + # Should raise EnrichedException, not generic Exception + with pytest.raises(EnrichedException) as exc_info: + await attachments_service.delete_async(key=attachment_key) + + assert exc_info.value.status_code == status_code diff --git a/tests/sdk/services/test_folder_service_key_retrieval.py b/tests/sdk/services/test_folder_service_key_retrieval.py new file mode 100644 index 000000000..2554327b8 --- /dev/null +++ b/tests/sdk/services/test_folder_service_key_retrieval.py @@ -0,0 +1,744 @@ +"""Tests for FolderService key retrieval and performance - Phase 4b.""" + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._config import Config +from uipath._execution_context import ExecutionContext +from uipath._services.folder_service import FolderService + + +@pytest.fixture +def folder_service( + config: Config, execution_context: ExecutionContext +) -> FolderService: + """FolderService fixture for testing.""" + return FolderService(config=config, execution_context=execution_context) + + +class TestFolderServiceKeyRetrieval: + """Test FolderService retrieve methods and O(n) performance characteristics.""" + + def test_retrieve_by_key_makes_multiple_requests_for_pagination( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() by key iterates through list() (O(n) behavior).""" + # Mock first page of folders (full page of 100, no match) + httpx_mock.add_response( + json={ + "value": [ + { + "Key": f"folder-key-{i}", + "DisplayName": f"Folder{i}", + "FullyQualifiedName": f"Shared/Folder{i}", + "Id": i, + } + for i in range(1, 101) # 100 items to trigger next page + ] + } + ) + + # Mock second page with target folder + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-target", + "DisplayName": "TargetFolder", + "FullyQualifiedName": "Shared/TargetFolder", + "Id": 101, + } + ] + } + ) + + folder = folder_service.retrieve(key="folder-key-target") + + assert folder.key == "folder-key-target" + assert folder.display_name == "TargetFolder" + + # Verify multiple requests were made (pagination through list) + requests = httpx_mock.get_requests() + assert len(requests) == 2 # Two pages fetched + + def test_retrieve_by_key_raises_lookup_error_if_not_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() by key raises LookupError when folder not found.""" + # Mock empty response + httpx_mock.add_response(json={"value": []}) + + with pytest.raises( + LookupError, match="Folder with key 'non-existent' not found" + ): + folder_service.retrieve(key="non-existent") + + def test_retrieve_by_display_name_uses_odata_filter( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() by display_name uses efficient OData filter (not O(n)).""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve(display_name="Finance") + + assert folder.key == "folder-key-123" + assert folder.display_name == "Finance" + + request = httpx_mock.get_request() + assert request is not None + assert "%24filter" in str(request.url) or "$filter" in str(request.url) + assert "DisplayName+eq" in str(request.url) + + def test_retrieve_by_display_name_escapes_quotes( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() by display_name escapes single quotes for OData.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Client's Folder", + "FullyQualifiedName": "Shared/Client's Folder", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve(display_name="Client's Folder") + + assert folder.display_name == "Client's Folder" + + request = httpx_mock.get_request() + assert request is not None + # Verify quote escaping: ' becomes '' in OData filter + url_str = str(request.url) + assert "Client''s" in url_str or "Client%27%27s" in url_str + + def test_retrieve_by_display_name_raises_lookup_error_if_not_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() by display_name raises LookupError when not found.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises( + LookupError, match="Folder with display_name 'NonExistent' not found" + ): + folder_service.retrieve(display_name="NonExistent") + + def test_retrieve_requires_key_or_display_name( + self, + folder_service: FolderService, + ) -> None: + """Test retrieve() raises ValueError if neither key nor display_name provided.""" + with pytest.raises( + ValueError, match="Either 'key' or 'display_name' must be provided" + ): + folder_service.retrieve() + + def test_retrieve_by_path_uses_odata_filter( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_by_path() uses efficient OData filter.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve_by_path("Shared/Finance") + + assert folder.fully_qualified_name == "Shared/Finance" + + request = httpx_mock.get_request() + assert request is not None + assert "%24filter" in str(request.url) or "$filter" in str(request.url) + assert "FullyQualifiedName+eq" in str(request.url) + + def test_retrieve_by_path_escapes_quotes( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_by_path() escapes single quotes in path.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Client's Folder", + "FullyQualifiedName": "Shared/Client's Folder", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve_by_path("Shared/Client's Folder") + + assert folder.fully_qualified_name == "Shared/Client's Folder" + + # Verify quote escaping: ' becomes '' in OData filter + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + assert "Client''s" in url_str or "Client%27%27s" in url_str + + def test_retrieve_by_path_raises_lookup_error_if_not_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_by_path() raises LookupError when path not found.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises( + LookupError, match="Folder with path 'Shared/NonExistent' not found" + ): + folder_service.retrieve_by_path("Shared/NonExistent") + + def test_retrieve_key_paginates_through_folders( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_key() paginates through folders to find key.""" + # Mock first page (full page of 20, no match) - take=20 in retrieve_key + httpx_mock.add_response( + json={ + "PageItems": [ + {"Key": f"folder-{i}", "FullyQualifiedName": f"Shared/Folder{i}"} + for i in range(1, 21) # 20 items to trigger next page + ] + } + ) + + # Mock second page with target + httpx_mock.add_response( + json={ + "PageItems": [ + {"Key": "folder-target", "FullyQualifiedName": "Shared/Finance"}, + ] + } + ) + + key = folder_service.retrieve_key(folder_path="Shared/Finance") + + assert key == "folder-target" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + def test_retrieve_key_returns_none_if_not_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_key() returns None when folder path not found.""" + # Mock page with no match and less than take count (end of results) + httpx_mock.add_response( + json={ + "PageItems": [ + {"Key": "folder-1", "FullyQualifiedName": "Shared/Folder1"}, + ] + } + ) + + key = folder_service.retrieve_key(folder_path="Shared/NonExistent") + + assert key is None + + def test_retrieve_key_searches_using_folder_name( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_key() uses folder name (last segment) in search.""" + httpx_mock.add_response( + json={ + "PageItems": [ + {"Key": "folder-key-123", "FullyQualifiedName": "Shared/Finance"}, + ] + } + ) + + folder_service.retrieve_key(folder_path="Shared/Finance") + + # Verify search text uses only "Finance" (last segment) + request = httpx_mock.get_request() + assert request is not None + assert "searchText=Finance" in str(request.url) + + def test_list_paginates_automatically( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test list() automatically paginates through all folders.""" + # Mock first page (full page, triggers next request) + httpx_mock.add_response( + json={ + "value": [ + { + "Key": f"folder-{i}", + "DisplayName": f"Folder{i}", + "FullyQualifiedName": f"Shared/Folder{i}", + "Id": i, + } + for i in range(1, 101) # 100 items (default page size) + ] + } + ) + + # Mock second page (partial, end of results) + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-101", + "DisplayName": "Folder101", + "FullyQualifiedName": "Shared/Folder101", + "Id": 101, + } + ] + } + ) + + folders = list(folder_service.list()) + + assert len(folders) == 101 # 100 + 1 + assert folders[0].key == "folder-1" + assert folders[100].key == "folder-101" + + def test_list_with_filter( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test list() with OData filter parameter.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-1", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance", + "Id": 1, + } + ] + } + ) + + folders = list(folder_service.list(filter="DisplayName eq 'Finance'")) + + assert len(folders) == 1 + assert folders[0].display_name == "Finance" + + request = httpx_mock.get_request() + assert request is not None + assert "%24filter" in str(request.url) or "$filter" in str(request.url) + + def test_list_with_orderby( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test list() with OData orderby parameter.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-1", + "DisplayName": "A-Folder", + "FullyQualifiedName": "Shared/A-Folder", + "Id": 1, + }, + { + "Key": "folder-2", + "DisplayName": "B-Folder", + "FullyQualifiedName": "Shared/B-Folder", + "Id": 2, + }, + ] + } + ) + + folders = list(folder_service.list(orderby="DisplayName asc")) + + assert len(folders) == 2 + + request = httpx_mock.get_request() + assert request is not None + assert "%24orderby" in str(request.url) or "$orderby" in str(request.url) + + def test_exists_returns_true_when_folder_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists() returns True when folder is found.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance", + "Id": 1, + } + ] + } + ) + + exists = folder_service.exists(display_name="Finance") + + assert exists is True + + def test_exists_returns_false_when_folder_not_found( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists() returns False when folder not found.""" + httpx_mock.add_response(json={"value": []}) + + exists = folder_service.exists(display_name="NonExistent") + + assert exists is False + + @pytest.mark.asyncio + async def test_retrieve_async_by_key( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_async() by key works identically to sync version.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-async", + "DisplayName": "AsyncFolder", + "FullyQualifiedName": "Shared/AsyncFolder", + "Id": 1, + } + ] + } + ) + + folder = await folder_service.retrieve_async(key="folder-key-async") + + assert folder.key == "folder-key-async" + assert folder.display_name == "AsyncFolder" + + @pytest.mark.asyncio + async def test_list_async_paginates( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test list_async() paginates through folders.""" + # Mock first page + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-1", + "DisplayName": "Folder1", + "FullyQualifiedName": "Shared/Folder1", + "Id": 1, + } + ] + } + ) + + folders = [] + async for folder in folder_service.list_async(): + folders.append(folder) + + assert len(folders) == 1 + assert folders[0].key == "folder-1" + + @pytest.mark.asyncio + async def test_retrieve_async_by_display_name( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_async() by display_name uses OData filter.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-456", + "DisplayName": "AsyncFolder", + "FullyQualifiedName": "Shared/AsyncFolder", + "Id": 1, + } + ] + } + ) + + folder = await folder_service.retrieve_async(display_name="AsyncFolder") + + assert folder.key == "folder-key-456" + assert folder.display_name == "AsyncFolder" + + # Verify OData filter was used + request = httpx_mock.get_request() + assert request is not None + assert "%24filter" in str(request.url) or "$filter" in str(request.url) + + @pytest.mark.asyncio + async def test_retrieve_by_path_async( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_by_path_async() uses OData filter for path.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-789", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance/Invoices", + "Id": 1, + } + ] + } + ) + + folder = await folder_service.retrieve_by_path_async("Shared/Finance/Invoices") + + assert folder.fully_qualified_name == "Shared/Finance/Invoices" + assert folder.key == "folder-key-789" + + @pytest.mark.asyncio + async def test_exists_async_returns_true( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists_async() returns True when folder is found.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-exists", + "DisplayName": "ExistingFolder", + "FullyQualifiedName": "Shared/ExistingFolder", + "Id": 1, + } + ] + } + ) + + exists = await folder_service.exists_async(display_name="ExistingFolder") + + assert exists is True + + @pytest.mark.asyncio + async def test_exists_async_returns_false( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test exists_async() returns False when folder not found.""" + httpx_mock.add_response(json={"value": []}) + + exists = await folder_service.exists_async(display_name="NonExistent") + + assert exists is False + + @pytest.mark.asyncio + async def test_list_async_with_filter( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test list_async() with OData filter parameter.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-filtered", + "DisplayName": "Finance", + "FullyQualifiedName": "Shared/Finance", + "Id": 1, + } + ] + } + ) + + folders = [] + async for folder in folder_service.list_async( + filter="DisplayName eq 'Finance'" + ): + folders.append(folder) + + assert len(folders) == 1 + assert folders[0].display_name == "Finance" + + request = httpx_mock.get_request() + assert request is not None + assert "%24filter" in str(request.url) or "$filter" in str(request.url) + + @pytest.mark.asyncio + async def test_retrieve_async_raises_lookup_error( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_async() raises LookupError when folder not found.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises( + LookupError, match="Folder with display_name 'NotFound' not found" + ): + await folder_service.retrieve_async(display_name="NotFound") + + def test_retrieve_with_key_and_display_name_prefers_key( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve() with both key and display_name uses key (key wins).""" + # Mock response for key-based retrieval + # Note: retrieve() with key iterates all folders, but we only return one + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "KeyFolder", + "FullyQualifiedName": "Shared/KeyFolder", + "Id": 1, + } + ] + } + ) + + # Provide BOTH key and display_name - key should win + folder = folder_service.retrieve( + key="folder-key-123", display_name="IgnoredDisplayName" + ) + + assert folder.key == "folder-key-123" + assert folder.display_name == "KeyFolder" + + # Verify that the request used key-based retrieval (no $filter) + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # When using key, there's no $filter for DisplayName + # The implementation uses top=100 to paginate through all folders + assert "$top" in url_str or "%24top" in url_str + # Should NOT filter by DisplayName when key is provided + assert "DisplayName" not in url_str diff --git a/tests/sdk/services/test_jobs_service.py b/tests/sdk/services/test_jobs_service.py index 3b3b5a56f..f95519777 100644 --- a/tests/sdk/services/test_jobs_service.py +++ b/tests/sdk/services/test_jobs_service.py @@ -123,7 +123,7 @@ def test_retrieve( }, ) - job = service.retrieve(job_key) + job = service.retrieve(job_key=job_key) assert isinstance(job, Job) assert job.key == job_key @@ -167,7 +167,7 @@ async def test_retrieve_async( }, ) - job = await service.retrieve_async(job_key) + job = await service.retrieve_async(job_key=job_key) assert isinstance(job, Job) assert job.key == job_key @@ -947,7 +947,7 @@ def test_retrieve_job_with_large_output_integration( content=large_output_content.encode("utf-8"), ) - job = service.retrieve(job_key) + job = service.retrieve(job_key=job_key) # job structure is correct for large output assert job.key == job_key @@ -1040,7 +1040,7 @@ async def test_retrieve_job_with_large_output_integration_async( content=large_output_content.encode("utf-8"), ) - job = await service.retrieve_async(job_key) + job = await service.retrieve_async(job_key=job_key) assert job.key == job_key assert job.state == "Successful" @@ -1110,8 +1110,8 @@ def test_retrieve_job_with_small_output_vs_large_output( }, ) - small_job = service.retrieve(small_job_key) - large_job = service.retrieve(large_job_key) + small_job = service.retrieve(job_key=small_job_key) + large_job = service.retrieve(job_key=large_job_key) assert small_job.output_arguments == small_output assert small_job.output_file is None diff --git a/tests/sdk/services/test_jobs_service_operations.py b/tests/sdk/services/test_jobs_service_operations.py new file mode 100644 index 000000000..981028aa4 --- /dev/null +++ b/tests/sdk/services/test_jobs_service_operations.py @@ -0,0 +1,466 @@ +"""Comprehensive tests for JobsService operations. + +Tests the ACTUAL implemented API signatures for job operations: +- list() with OData filtering and pagination (no expand/select) +- retrieve() by job_key or job_id (keyword-only arguments) +- exists() boolean checks by job_key +- stop() one or more jobs by job_keys list or job_ids list (with strategy parameter) +- Async variants for all operations +""" + +from typing import Any, Dict + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._services.jobs_service import JobsService +from uipath.models.job import Job + + +@pytest.fixture +def job_data() -> Dict[str, Any]: + """Sample job response data.""" + return { + "Key": "job-123-abc", + "Id": 12345, + "State": "Successful", + "Info": "Job completed successfully", + "CreationTime": "2024-01-15T10:30:00Z", + "StartingTime": "2024-01-15T10:30:05Z", + "EndTime": "2024-01-15T10:35:00Z", + "ReleaseName": "MyProcess", + "Type": "Unattended", + "InputArguments": '{"arg1": "value1"}', + "OutputArguments": '{"result": "success"}', + } + + +@pytest.fixture +def jobs_list_data(job_data: Dict[str, Any]) -> Dict[str, Any]: + """Sample jobs list response.""" + return { + "@odata.context": "https://test.uipath.com/odata/$metadata#Jobs", + "@odata.count": 2, + "value": [ + job_data, + { + "Key": "job-456-def", + "Id": 67890, + "State": "Running", + "Info": "Job in progress", + "CreationTime": "2024-01-15T11:00:00Z", + "StartingTime": "2024-01-15T11:00:05Z", + "ReleaseName": "AnotherProcess", + "Type": "Unattended", + }, + ], + } + + +class TestJobsServiceList: + """Tests for JobsService.list() method.""" + + def test_list_jobs_basic( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + jobs_list_data: Dict[str, Any], + ) -> None: + """Test basic job listing.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=100&%24skip=0", + json=jobs_list_data, + ) + + jobs = list(jobs_service.list()) + + assert len(jobs) == 2 + assert all(isinstance(j, Job) for j in jobs) + assert jobs[0].key == "job-123-abc" + assert jobs[1].key == "job-456-def" + + def test_list_jobs_with_filter( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test listing jobs with OData filter.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24filter=State+eq+%27Successful%27&%24top=100&%24skip=0", + json={"value": [job_data]}, + ) + + jobs = list(jobs_service.list(filter="State eq 'Successful'")) + + assert len(jobs) == 1 + assert jobs[0].state == "Successful" + + def test_list_jobs_with_orderby( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + jobs_list_data: Dict[str, Any], + ) -> None: + """Test listing jobs with ordering.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24orderby=CreationTime+desc&%24top=100&%24skip=0", + json=jobs_list_data, + ) + + jobs = list(jobs_service.list(orderby="CreationTime desc")) + + assert len(jobs) == 2 + + def test_list_jobs_with_pagination( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test listing jobs with pagination parameters.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=10&%24skip=5", + json={"value": [job_data]}, + ) + + jobs = list(jobs_service.list(top=10, skip=5)) + + assert len(jobs) == 1 + + def test_list_jobs_auto_pagination( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test automatic pagination when more results available.""" + # First page + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=1&%24skip=0", + json={"value": [job_data]}, + ) + # Second page (empty) + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=1&%24skip=1", + json={"value": []}, + ) + + jobs = list(jobs_service.list(top=1)) + + assert len(jobs) == 1 + + def test_list_jobs_with_folder_path( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + jobs_list_data: Dict[str, Any], + ) -> None: + """Test listing jobs with folder_path parameter.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=100&%24skip=0", + json=jobs_list_data, + ) + + jobs = list(jobs_service.list(folder_path="Shared")) + + assert len(jobs) == 2 + + def test_list_jobs_with_combined_filters( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test listing jobs with multiple filter parameters.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24filter=State+eq+%27Successful%27+and+ReleaseName+eq+%27MyProcess%27&%24orderby=CreationTime+desc&%24top=50&%24skip=0", + json={"value": [job_data]}, + ) + + jobs = list( + jobs_service.list( + filter="State eq 'Successful' and ReleaseName eq 'MyProcess'", + orderby="CreationTime desc", + top=50, + ) + ) + + assert len(jobs) == 1 + + +class TestJobsServiceRetrieve: + """Tests for JobsService.retrieve() method.""" + + def test_retrieve_job_by_key( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test retrieving a job by its key.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json=job_data, + ) + + job = jobs_service.retrieve(job_key="job-123-abc") + + assert isinstance(job, Job) + assert job.key == "job-123-abc" + assert job.state == "Successful" + + def test_retrieve_job_not_found( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test retrieving non-existent job raises LookupError.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=invalid-key)", + status_code=404, + ) + + with pytest.raises(LookupError, match="Job with key 'invalid-key' not found"): + jobs_service.retrieve(job_key="invalid-key") + + def test_retrieve_job_with_folder_path( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test retrieving a job with folder context.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json=job_data, + ) + + job = jobs_service.retrieve(job_key="job-123-abc", folder_path="Shared") + + assert job.key == "job-123-abc" + + +class TestJobsServiceExists: + """Tests for JobsService.exists() method.""" + + def test_exists_job_true( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test exists check returns True when job exists.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json=job_data, + ) + + exists = jobs_service.exists(job_key="job-123-abc") + + assert exists is True + + def test_exists_job_false( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test exists check returns False when job doesn't exist.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=invalid-key)", + status_code=404, + ) + + exists = jobs_service.exists(job_key="invalid-key") + + assert exists is False + + +class TestJobsServiceStop: + """Tests for JobsService.stop() method.""" + + def test_stop_single_job( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test stopping a single job by key.""" + # Mock retrieve endpoint (stop() calls retrieve() to get job ID) + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json={"Key": "job-123-abc", "Id": 12345, "State": "Running"}, + ) + + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs", + status_code=204, + ) + + jobs_service.stop(job_keys=["job-123-abc"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 # retrieve + stop + assert requests[0].method == "GET" # retrieve + assert requests[1].method == "POST" # stop + + def test_stop_job_with_folder_path( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test stopping a job with folder context.""" + # Mock retrieve endpoint (stop() calls retrieve() to get job ID) + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json={"Key": "job-123-abc", "Id": 12345, "State": "Running"}, + ) + + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs", + status_code=204, + ) + + jobs_service.stop(job_keys=["job-123-abc"], folder_path="Shared") + + requests = httpx_mock.get_requests() + assert len(requests) == 2 # retrieve + stop + + +class TestJobsServiceAsync: + """Tests for async variants of job operations.""" + + @pytest.mark.asyncio + async def test_list_jobs_async( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + jobs_list_data: Dict[str, Any], + ) -> None: + """Test async job listing.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24top=100&%24skip=0", + json=jobs_list_data, + ) + + jobs = [] + async for job in jobs_service.list_async(): + jobs.append(job) + + assert len(jobs) == 2 + assert all(isinstance(j, Job) for j in jobs) + + @pytest.mark.asyncio + async def test_retrieve_job_async( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test async job retrieval.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json=job_data, + ) + + job = await jobs_service.retrieve_async(job_key="job-123-abc") + + assert isinstance(job, Job) + assert job.key == "job-123-abc" + + @pytest.mark.asyncio + async def test_exists_job_async( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + job_data: Dict[str, Any], + ) -> None: + """Test async job existence check.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json=job_data, + ) + + exists = await jobs_service.exists_async(job_key="job-123-abc") + + assert exists is True + + @pytest.mark.asyncio + async def test_stop_job_async( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test async job stop.""" + # Mock retrieve endpoint (stop_async() calls retrieve_async() to get job ID) + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=job-123-abc)", + json={"Key": "job-123-abc", "Id": 12345, "State": "Running"}, + ) + + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs", + status_code=204, + ) + + await jobs_service.stop_async(job_keys=["job-123-abc"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 # retrieve + stop + + +class TestJobsServiceFieldMapping: + """Tests for job field mapping and type conversions.""" + + def test_job_state_mapping( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test that job states are correctly mapped.""" + for state in ["Pending", "Running", "Successful", "Faulted", "Stopped"]: + httpx_mock.add_response( + method="GET", + url=f"https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24filter=State+eq+%27{state}%27&%24top=100&%24skip=0", + json={"value": [{"Key": f"job-{state}", "Id": 1, "State": state}]}, + ) + + jobs = list(jobs_service.list(filter=f"State eq '{state}'")) + assert jobs[0].state == state + + def test_job_type_mapping( + self, + jobs_service: JobsService, + httpx_mock: HTTPXMock, + ) -> None: + """Test that job types are correctly mapped.""" + for job_type in ["Unattended", "Attended", "Development"]: + httpx_mock.add_response( + method="GET", + url=f"https://test.uipath.com/org/tenant/orchestrator_/odata/Jobs?%24filter=Type+eq+%27{job_type}%27&%24top=100&%24skip=0", + json={"value": [{"Key": f"job-{job_type}", "Id": 1, "Type": job_type}]}, + ) + + jobs = list(jobs_service.list(filter=f"Type eq '{job_type}'")) + assert jobs[0].model_extra.get("Type") == job_type diff --git a/tests/sdk/services/test_processes_service_invoke.py b/tests/sdk/services/test_processes_service_invoke.py new file mode 100644 index 000000000..3b5a8a4f9 --- /dev/null +++ b/tests/sdk/services/test_processes_service_invoke.py @@ -0,0 +1,310 @@ +"""Tests for ProcessesService invoke() method - Gap 1 from AI review.""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._services.processes_service import ProcessesService +from uipath.models.job import Job + + +class TestProcessesServiceInvoke: + """Test ProcessesService invoke() method creates jobs correctly.""" + + def test_invoke_creates_job( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() sends correct POST request to start job.""" + # Mock job creation response + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 123, + "Key": "job-123", + "State": "Pending", + "Release": {"Name": "Process1"}, + } + ] + } + ) + + job = processes_service.invoke(name="Process1") + + assert isinstance(job, Job) + assert job.key == "job-123" + assert job.state == "Pending" + assert job.id == 123 + + # Verify request + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert "StartJobs" in str(request.url) + + def test_invoke_with_input_arguments( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() passes input arguments correctly.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 124, + "Key": "job-123", + "State": "Pending", + "InputArguments": '{"arg1": "value1"}', + } + ] + } + ) + + job = processes_service.invoke( + name="Process1", input_arguments={"arg1": "value1"} + ) + + assert job.key == "job-123" + assert job.input_arguments == '{"arg1": "value1"}' + + # Verify the request payload sent to the API + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["startInfo"]["ReleaseName"] == "Process1" + # Input arguments are serialized to JSON string in the payload + assert "InputArguments" in body["startInfo"] + + def test_invoke_with_folder_path( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() sends folder path header.""" + httpx_mock.add_response( + json={"value": [{"Id": 125, "Key": "job-123", "State": "Pending"}]} + ) + + processes_service.invoke(name="Process1", folder_path="Shared/Finance") + + # Verify folder header (handled by base service) + request = httpx_mock.get_request() + assert request is not None + + def test_invoke_with_folder_key( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() sends folder key header.""" + httpx_mock.add_response( + json={"value": [{"Id": 126, "Key": "job-123", "State": "Pending"}]} + ) + + processes_service.invoke(name="Process1", folder_key="folder-key-123") + + request = httpx_mock.get_request() + assert request is not None + + def test_invoke_returns_job_with_release( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() returns Job with release information.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 127, + "Key": "job-123", + "State": "Pending", + "Release": { + "Name": "Process1", + "ProcessVersion": "1.0.0", + "Key": "release-key", + }, + } + ] + } + ) + + job = processes_service.invoke(name="Process1") + + assert isinstance(job, Job) + assert job.release is not None + assert job.release["Name"] == "Process1" + + def test_invoke_response_parsing( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() correctly parses response from value array.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 456, + "Key": "job-456", + "State": "Running", + } + ] + } + ) + + job = processes_service.invoke(name="Process2") + + assert job.key == "job-456" + assert job.state == "Running" + assert job.id == 456 + + @pytest.mark.asyncio + async def test_invoke_async_creates_job( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke_async() sends correct POST request to start job.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 200, + "Key": "job-async-123", + "State": "Pending", + } + ] + } + ) + + job = await processes_service.invoke_async(name="Process1") + + assert job.key == "job-async-123" + assert job.state == "Pending" + assert job.id == 200 + + @pytest.mark.asyncio + async def test_invoke_async_with_input_arguments( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke_async() passes input arguments correctly.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 201, + "Key": "job-async-123", + "State": "Pending", + "InputArguments": '{"arg1": "value1"}', + } + ] + } + ) + + job = await processes_service.invoke_async( + name="Process1", input_arguments={"arg1": "value1"} + ) + + assert job.key == "job-async-123" + assert job.input_arguments == '{"arg1": "value1"}' + + def test_invoke_request_body_structure( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() sends correct request body structure.""" + httpx_mock.add_response( + json={"value": [{"Id": 300, "Key": "job-123", "State": "Pending"}]} + ) + + processes_service.invoke(name="TestProcess") + + request = httpx_mock.get_request() + assert request is not None + + # Verify request uses POST method + assert request.method == "POST" + + # Verify correct OData endpoint + assert "orchestrator_/odata/Jobs" in str(request.url) + assert "StartJobs" in str(request.url) + + def test_invoke_with_runtime_strategy( + self, + httpx_mock: HTTPXMock, + processes_service: ProcessesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoke() with Strategy parameter for specific release version.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Id": 301, + "Key": "job-123", + "State": "Pending", + "Release": { + "Name": "Process1", + "ProcessVersion": "2.0.0", + "Key": "release-key-v2", + }, + } + ] + } + ) + + # Strategy: "Specific" with RuntimeType can be used to invoke a specific version + # These parameters go into the startInfo along with ReleaseName + job = processes_service.invoke( + name="Process1", + input_arguments={"Strategy": "Specific", "RuntimeType": "Production"}, + ) + + assert job.key == "job-123" + assert job.release is not None + assert job.release["ProcessVersion"] == "2.0.0" + + # Verify request body includes Strategy + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["startInfo"]["ReleaseName"] == "Process1" + # Strategy and RuntimeType are passed as part of input arguments + assert "InputArguments" in body["startInfo"] diff --git a/tests/sdk/services/test_queues_service.py b/tests/sdk/services/test_queues_service.py index 10b849d1c..e240244c8 100644 --- a/tests/sdk/services/test_queues_service.py +++ b/tests/sdk/services/test_queues_service.py @@ -37,7 +37,7 @@ def test_list_items( version: str, ) -> None: httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems?%24skip=0&%24top=100", status_code=200, json={ "value": [ @@ -50,11 +50,12 @@ def test_list_items( }, ) - response = service.list_items() + items = list(service.list_items()) - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" + assert len(items) == 1 + assert items[0].Id == 1 # Id is an extra field in the model + assert items[0].name == "test-queue" + assert items[0].priority == "High" sent_request = httpx_mock.get_request() if sent_request is None: @@ -63,7 +64,7 @@ def test_list_items( assert sent_request.method == "GET" assert ( sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems?%24skip=0&%24top=100" ) assert HEADER_USER_AGENT in sent_request.headers @@ -83,7 +84,7 @@ async def test_list_items_async( version: str, ) -> None: httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems?%24skip=0&%24top=100", status_code=200, json={ "value": [ @@ -96,11 +97,14 @@ async def test_list_items_async( }, ) - response = await service.list_items_async() + items = [] + async for item in service.list_items_async(): + items.append(item) - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" + assert len(items) == 1 + assert items[0].Id == 1 # Id is an extra field in the model + assert items[0].name == "test-queue" + assert items[0].priority == "High" sent_request = httpx_mock.get_request() if sent_request is None: @@ -109,7 +113,7 @@ async def test_list_items_async( assert sent_request.method == "GET" assert ( sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems?%24skip=0&%24top=100" ) assert HEADER_USER_AGENT in sent_request.headers @@ -127,28 +131,31 @@ def test_create_item( tenant: str, version: str, ) -> None: - queue_item = QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ) httpx_mock.add_response( url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", status_code=200, json={ "Id": 1, "Name": "test-queue", + "Reference": "test-ref-001", "Priority": "High", "SpecificContent": {"key": "value"}, }, ) - response = service.create_item(queue_item) + response = service.create_item( + queue_name="test-queue", + reference="test-ref-001", + specific_content={"key": "value"}, + priority="High", + ) - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"key": "value"} + # Access via model_extra since id and reference are not defined fields + assert response.model_extra.get("Id") == 1 + assert response.name == "test-queue" + assert response.model_extra.get("Reference") == "test-ref-001" + assert response.priority == "High" + assert response.specific_content == {"key": "value"} sent_request = httpx_mock.get_request() if sent_request is None: @@ -159,13 +166,11 @@ def test_create_item( sent_request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - } - } + request_body = json.loads(sent_request.content.decode()) + assert request_body["itemData"]["Name"] == "test-queue" + assert request_body["itemData"]["Reference"] == "test-ref-001" + assert request_body["itemData"]["Priority"] == "High" + assert request_body["itemData"]["SpecificContent"] == {"key": "value"} assert HEADER_USER_AGENT in sent_request.headers assert ( @@ -183,28 +188,31 @@ async def test_create_item_async( tenant: str, version: str, ) -> None: - queue_item = QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ) httpx_mock.add_response( url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", status_code=200, json={ "Id": 1, "Name": "test-queue", + "Reference": "test-ref-001", "Priority": "High", "SpecificContent": {"key": "value"}, }, ) - response = await service.create_item_async(queue_item) + response = await service.create_item_async( + queue_name="test-queue", + reference="test-ref-001", + specific_content={"key": "value"}, + priority="High", + ) - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"key": "value"} + # Access via model_extra since id and reference are not defined fields + assert response.model_extra.get("Id") == 1 + assert response.name == "test-queue" + assert response.model_extra.get("Reference") == "test-ref-001" + assert response.priority == "High" + assert response.specific_content == {"key": "value"} sent_request = httpx_mock.get_request() if sent_request is None: @@ -215,13 +223,11 @@ async def test_create_item_async( sent_request.url == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - } - } + request_body = json.loads(sent_request.content.decode()) + assert request_body["itemData"]["Name"] == "test-queue" + assert request_body["itemData"]["Reference"] == "test-ref-001" + assert request_body["itemData"]["Priority"] == "High" + assert request_body["itemData"]["SpecificContent"] == {"key": "value"} assert HEADER_USER_AGENT in sent_request.headers assert ( @@ -686,3 +692,46 @@ async def test_complete_transaction_item_async( sent_request.headers[HEADER_USER_AGENT] == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.complete_transaction_item_async/{version}" ) + + def test_create_item_requires_reference(self, service: QueuesService) -> None: + """Test that create_item() requires the reference parameter.""" + with pytest.raises(TypeError, match="reference"): + service.create_item( + queue_name="test-queue", specific_content={"key": "value"} + ) + + def test_create_item_requires_specific_content( + self, service: QueuesService + ) -> None: + """Test that create_item() requires the specific_content parameter.""" + with pytest.raises(TypeError, match="specific_content"): + service.create_item(queue_name="test-queue", reference="REF-001") + + def test_create_item_requires_queue_selector(self, service: QueuesService) -> None: + """Test that create_item() requires at least one of queue_name or queue_key.""" + with pytest.raises( + ValueError, match="Either 'queue_name' or 'queue_key' must be provided" + ): + service.create_item(reference="REF-001", specific_content={"key": "value"}) + + @pytest.mark.asyncio + async def test_create_item_async_requires_reference( + self, service: QueuesService + ) -> None: + """Test that create_item_async() requires the reference parameter.""" + with pytest.raises(TypeError, match="reference"): + await service.create_item_async( + queue_name="test-queue", specific_content={"key": "value"} + ) + + @pytest.mark.asyncio + async def test_create_item_async_requires_queue_selector( + self, service: QueuesService + ) -> None: + """Test that create_item_async() requires at least one of queue_name or queue_key.""" + with pytest.raises( + ValueError, match="Either 'queue_name' or 'queue_key' must be provided" + ): + await service.create_item_async( + reference="REF-001", specific_content={"key": "value"} + ) diff --git a/tests/sdk/services/test_queues_service_definitions.py b/tests/sdk/services/test_queues_service_definitions.py new file mode 100644 index 000000000..a184d9c60 --- /dev/null +++ b/tests/sdk/services/test_queues_service_definitions.py @@ -0,0 +1,446 @@ +"""Comprehensive tests for QueuesService queue definition operations. + +Tests the ACTUAL implemented API signatures for queue definitions: +- list_definitions() with OData filtering and pagination +- retrieve_definition() by name or key +- create_definition() with various parameters +- delete_definition() by name or key +- exists_definition() boolean checks +- Async variants for all operations +""" + +from typing import Any, Dict + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._services.queues_service import QueuesService +from uipath.models.queues import QueueDefinition + + +@pytest.fixture +def queue_definition_data() -> Dict[str, Any]: + """Sample queue definition response data.""" + return { + "Name": "TestQueue", + "Key": "queue-123-abc", + "Description": "Test queue for unit tests", + "MaxNumberOfRetries": 3, + "AcceptAutomaticallyRetry": True, + "EnforceUniqueReference": False, + } + + +@pytest.fixture +def queue_definitions_list_data( + queue_definition_data: Dict[str, Any], +) -> Dict[str, Any]: + """Sample queue definitions list response.""" + return { + "@odata.context": "https://test.uipath.com/odata/$metadata#QueueDefinitions", + "@odata.count": 2, + "value": [ + queue_definition_data, + { + "Name": "SecondQueue", + "Key": "queue-456-def", + "Description": "Second test queue", + "MaxNumberOfRetries": 5, + "AcceptAutomaticallyRetry": False, + "EnforceUniqueReference": True, + }, + ], + } + + +class TestQueuesServiceListDefinitions: + """Tests for QueuesService.list_definitions() method.""" + + def test_list_definitions_basic( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definitions_list_data: Dict[str, Any], + ) -> None: + """Test basic queue definitions listing.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24top=100&%24skip=0", + json=queue_definitions_list_data, + ) + + definitions = list(queues_service.list_definitions()) + + assert len(definitions) == 2 + assert all(isinstance(d, QueueDefinition) for d in definitions) + assert definitions[0].name == "TestQueue" + assert definitions[1].name == "SecondQueue" + + def test_list_definitions_with_filter( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test listing queue definitions with OData filter.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=100&%24skip=0", + json={"value": [queue_definition_data]}, + ) + + definitions = list( + queues_service.list_definitions(filter="Name eq 'TestQueue'") + ) + + assert len(definitions) == 1 + assert definitions[0].name == "TestQueue" + + def test_list_definitions_with_orderby( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definitions_list_data: Dict[str, Any], + ) -> None: + """Test listing queue definitions with ordering.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24orderby=Name+desc&%24top=100&%24skip=0", + json=queue_definitions_list_data, + ) + + definitions = list(queues_service.list_definitions(orderby="Name desc")) + + assert len(definitions) == 2 + + def test_list_definitions_with_pagination( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test listing queue definitions with pagination.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24top=10&%24skip=5", + json={"value": [queue_definition_data]}, + ) + + definitions = list(queues_service.list_definitions(top=10, skip=5)) + + assert len(definitions) == 1 + + def test_list_definitions_auto_pagination( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test automatic pagination when more results available.""" + # First page + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24top=1&%24skip=0", + json={"value": [queue_definition_data]}, + ) + # Second page (empty) + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24top=1&%24skip=1", + json={"value": []}, + ) + + definitions = list(queues_service.list_definitions(top=1)) + + assert len(definitions) == 1 + + +class TestQueuesServiceRetrieveDefinition: + """Tests for QueuesService.retrieve_definition() method.""" + + def test_retrieve_definition_by_name( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test retrieving queue definition by name.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=1", + json={"value": [queue_definition_data]}, + ) + + definition = queues_service.retrieve_definition(name="TestQueue") + + assert isinstance(definition, QueueDefinition) + assert definition.name == "TestQueue" + assert definition.key == "queue-123-abc" + + def test_retrieve_definition_by_key( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test retrieving queue definition by key.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Key+eq+%27queue-123-abc%27&%24top=1", + json={"value": [queue_definition_data]}, + ) + + definition = queues_service.retrieve_definition(key="queue-123-abc") + + assert isinstance(definition, QueueDefinition) + assert definition.name == "TestQueue" + assert definition.key == "queue-123-abc" + + def test_retrieve_definition_not_found_by_name( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + ) -> None: + """Test retrieving non-existent queue definition by name raises LookupError.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27NonExistent%27&%24top=1", + json={"value": []}, + ) + + with pytest.raises( + LookupError, + match="Queue definition with name 'NonExistent' or key 'None' not found", + ): + queues_service.retrieve_definition(name="NonExistent") + + def test_retrieve_definition_not_found_by_key( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + ) -> None: + """Test retrieving non-existent queue definition by key raises LookupError.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Key+eq+%27invalid-key%27&%24top=1", + json={"value": []}, + ) + + with pytest.raises( + LookupError, + match="Queue definition with name 'None' or key 'invalid-key' not found", + ): + queues_service.retrieve_definition(key="invalid-key") + + +class TestQueuesServiceCreateDefinition: + """Tests for QueuesService.create_definition() method.""" + + def test_create_definition_basic( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test creating a queue definition with basic parameters.""" + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions", + json=queue_definition_data, + ) + + definition = queues_service.create_definition(name="TestQueue") + + assert isinstance(definition, QueueDefinition) + assert definition.name == "TestQueue" + + def test_create_definition_with_description( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test creating a queue definition with description.""" + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions", + json=queue_definition_data, + ) + + definition = queues_service.create_definition( + name="TestQueue", description="Test queue for unit tests" + ) + + assert definition.description == "Test queue for unit tests" + + def test_create_definition_with_retry_settings( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test creating a queue definition with retry settings.""" + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions", + json=queue_definition_data, + ) + + definition = queues_service.create_definition( + name="TestQueue", + max_number_of_retries=3, + accept_automatically_retry=True, + ) + + assert definition.max_number_of_retries == 3 + assert definition.accept_automatically_retry is True + + +class TestQueuesServiceDeleteDefinition: + """Tests for QueuesService.delete_definition() method.""" + + def test_delete_definition_by_name( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test deleting a queue definition by name.""" + # First, retrieve to get the ID + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=1", + json={"value": [{"Id": 123, **queue_definition_data}]}, + ) + # Then delete by ID + httpx_mock.add_response( + method="DELETE", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions(123)", + status_code=204, + ) + + queues_service.delete_definition(name="TestQueue") + + # Verify both requests were made + requests = httpx_mock.get_requests() + assert len(requests) == 2 + assert requests[0].method == "GET" + assert requests[1].method == "DELETE" + + +class TestQueuesServiceExistsDefinition: + """Tests for QueuesService.exists_definition() method.""" + + def test_exists_definition_by_name_true( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test exists check returns True when queue definition exists.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=1", + json={"value": [queue_definition_data]}, + ) + + exists = queues_service.exists_definition(name="TestQueue") + + assert exists is True + + def test_exists_definition_by_name_false( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + ) -> None: + """Test exists check returns False when queue definition doesn't exist.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27NonExistent%27&%24top=1", + json={"value": []}, + ) + + exists = queues_service.exists_definition(name="NonExistent") + + assert exists is False + + +class TestQueuesServiceAsyncDefinitions: + """Tests for async variants of queue definition operations.""" + + @pytest.mark.asyncio + async def test_list_definitions_async( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definitions_list_data: Dict[str, Any], + ) -> None: + """Test async queue definitions listing.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24top=100&%24skip=0", + json=queue_definitions_list_data, + ) + + definitions = [] + async for definition in queues_service.list_definitions_async(): + definitions.append(definition) + + assert len(definitions) == 2 + assert all(isinstance(d, QueueDefinition) for d in definitions) + + @pytest.mark.asyncio + async def test_retrieve_definition_async( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test async queue definition retrieval.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=1", + json={"value": [queue_definition_data]}, + ) + + definition = await queues_service.retrieve_definition_async(name="TestQueue") + + assert isinstance(definition, QueueDefinition) + assert definition.name == "TestQueue" + + @pytest.mark.asyncio + async def test_create_definition_async( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test async queue definition creation.""" + httpx_mock.add_response( + method="POST", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions", + json=queue_definition_data, + ) + + definition = await queues_service.create_definition_async(name="TestQueue") + + assert isinstance(definition, QueueDefinition) + assert definition.name == "TestQueue" + + @pytest.mark.asyncio + async def test_exists_definition_async( + self, + queues_service: QueuesService, + httpx_mock: HTTPXMock, + queue_definition_data: Dict[str, Any], + ) -> None: + """Test async queue definition existence check.""" + httpx_mock.add_response( + method="GET", + url="https://test.uipath.com/org/tenant/orchestrator_/odata/QueueDefinitions?%24filter=Name+eq+%27TestQueue%27&%24top=1", + json={"value": [queue_definition_data]}, + ) + + exists = await queues_service.exists_definition_async(name="TestQueue") + + assert exists is True diff --git a/tests/sdk/services/test_security_odata_injection.py b/tests/sdk/services/test_security_odata_injection.py new file mode 100644 index 000000000..c6ad02716 --- /dev/null +++ b/tests/sdk/services/test_security_odata_injection.py @@ -0,0 +1,383 @@ +"""Tests for OData injection security - Phase 5a. + +This test suite validates that the SDK properly escapes user input in +safe parameters (name, display_name, folder_path) to prevent OData injection. + +Note: Raw OData parameters (filter, orderby) are intentionally NOT escaped +by design to allow advanced users to build complex queries. +""" + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._config import Config +from uipath._execution_context import ExecutionContext +from uipath._services.assets_service import AssetsService +from uipath._services.folder_service import FolderService + + +@pytest.fixture +def assets_service( + config: Config, execution_context: ExecutionContext +) -> AssetsService: + """AssetsService fixture for testing.""" + return AssetsService(config=config, execution_context=execution_context) + + +@pytest.fixture +def folder_service( + config: Config, execution_context: ExecutionContext +) -> FolderService: + """FolderService fixture for testing.""" + return FolderService(config=config, execution_context=execution_context) + + +class TestODataInjectionSecurity: + """Test OData injection protection for safe parameters. + + The SDK escapes these parameters automatically: + - AssetsService: 'name' parameter + - FolderService: 'display_name', 'folder_path' parameters + + Raw OData parameters (filter, orderby) are passed through as-is. + """ + + def test_asset_name_escapes_single_quotes( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset list() by name properly escapes single quotes.""" + httpx_mock.add_response(json={"value": []}) + + # The 'name' parameter IS escaped by the SDK + malicious_name = "Test' or '1'='1" + list(assets_service.list(name=malicious_name)) + + request = httpx_mock.get_request() + assert request is not None + # Verify single quote was escaped to two single quotes for OData + # URL encoding: ' becomes %27, so '' becomes %27%27 + url_str = str(request.url) + # Check for the escaped pattern in the contains() function + assert "Test" in url_str and ("''" in url_str or "%27%27" in url_str) + + def test_asset_name_with_sql_injection_attempt( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset name handles SQL injection attempt safely.""" + httpx_mock.add_response(json={"value": []}) + + malicious_name = "Test'; DROP TABLE Assets;--" + list(assets_service.list(name=malicious_name)) + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Verify the quote was escaped + assert "''" in url_str or "%27%27" in url_str + + def test_folder_display_name_escapes_quotes( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder retrieve() escapes quotes in display_name.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises(LookupError): + folder_service.retrieve(display_name="Test' or '1'='1") + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Verify quote escaping in the $filter parameter + assert "''" in url_str or "%27%27" in url_str + assert "DisplayName" in url_str + + def test_folder_path_escapes_quotes( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder retrieve_by_path() escapes quotes in path.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises(LookupError): + folder_service.retrieve_by_path("Shared/Test' or '1'='1") + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Verify quote escaping in FullyQualifiedName filter + assert "''" in url_str or "%27%27" in url_str + assert "FullyQualifiedName" in url_str + + def test_asset_name_with_special_characters_in_create( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset creation handles special characters in name.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "Test", + "StringValue": "test", + "ValueType": "Text", + } + ) + + asset = assets_service.create( + name="Test", + value="test", + value_type="Text", + ) + + assert asset.name == "Test" + + def test_asset_name_with_unicode_quotes( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset name handles unicode quote characters.""" + httpx_mock.add_response(json={"value": []}) + + # Unicode right single quotation mark + name_with_unicode = "Test\u2019value" + list(assets_service.list(name=name_with_unicode)) + + request = httpx_mock.get_request() + assert request is not None + # Just verify the request was made - unicode handling is at HTTP layer + assert "Test" in str(request.url) + + def test_folder_display_name_with_parentheses( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder retrieve() handles parentheses in display_name.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "Test(Folder)", + "FullyQualifiedName": "Shared/Test(Folder)", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve(display_name="Test(Folder)") + + assert folder.display_name == "Test(Folder)" + + def test_asset_name_with_null_byte( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset name handles null byte safely.""" + httpx_mock.add_response(json={"value": []}) + + malicious_name = "Test\x00" + list(assets_service.list(name=malicious_name)) + + request = httpx_mock.get_request() + assert request is not None + + def test_folder_path_with_path_traversal_attempt( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder retrieve_by_path() handles path traversal attempts.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises(LookupError): + folder_service.retrieve_by_path("Shared/../../Admin") + + request = httpx_mock.get_request() + assert request is not None + # Path is passed as-is in the filter, API validates + + def test_asset_value_with_json_injection( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset creation handles JSON-like values safely.""" + httpx_mock.add_response( + json={ + "Key": "asset-key-123", + "Name": "TestAsset", + "StringValue": '{"malicious": "value"}', + "ValueType": "Text", + } + ) + + asset = assets_service.create( + name="TestAsset", value='{"malicious": "value"}', value_type="Text" + ) + + assert asset.string_value == '{"malicious": "value"}' + + def test_legitimate_name_with_apostrophe( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test legitimate folder name with apostrophe works correctly.""" + httpx_mock.add_response( + json={ + "value": [ + { + "Key": "folder-key-123", + "DisplayName": "O'Brien's Folder", + "FullyQualifiedName": "Shared/O'Brien's Folder", + "Id": 1, + } + ] + } + ) + + folder = folder_service.retrieve(display_name="O'Brien's Folder") + + assert folder.display_name == "O'Brien's Folder" + assert folder.key == "folder-key-123" + + # Verify escaping happened in the request + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + assert "''" in url_str or "%27%27" in url_str + + def test_asset_name_with_very_long_string( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset name handles very long strings (>1000 chars) safely.""" + httpx_mock.add_response(json={"value": []}) + + # Create a very long name (>1000 chars) with injection attempt + long_name = "A" * 1000 + "' or '1'='1" + list(assets_service.list(name=long_name)) + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Verify quote was escaped even in long string + assert "''" in url_str or "%27%27" in url_str + + def test_folder_path_with_consecutive_quotes( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder path escapes multiple consecutive quotes.""" + httpx_mock.add_response(json={"value": []}) + + with pytest.raises(LookupError): + folder_service.retrieve_by_path("Shared/Test'''Folder") + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # All three consecutive quotes should be escaped to six single quotes + # (each ' becomes '') + # Looking for the escaped pattern in the URL + assert "''" in url_str or "%27%27" in url_str + + def test_asset_name_with_mixed_quote_types( + self, + httpx_mock: HTTPXMock, + assets_service: AssetsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test asset name with mixed quote types (single, double, backtick).""" + httpx_mock.add_response(json={"value": []}) + + # Mix of single quotes, double quotes, and backticks + mixed_quotes = "Test'Asset\"with`quotes" + list(assets_service.list(name=mixed_quotes)) + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Single quotes should be escaped, double quotes and backticks passed through + # OData only requires escaping single quotes + assert "''" in url_str or "%27%27" in url_str + # Double quotes and backticks are safe in OData + assert "Asset" in url_str + + def test_folder_display_name_with_crlf_injection( + self, + httpx_mock: HTTPXMock, + folder_service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test folder display_name with CRLF injection attempt.""" + httpx_mock.add_response(json={"value": []}) + + # CRLF injection attempt + crlf_name = "Folder\r\n' or '1'='1" + with pytest.raises(LookupError): + folder_service.retrieve(display_name=crlf_name) + + request = httpx_mock.get_request() + assert request is not None + url_str = str(request.url) + # Verify quote escaping happened + assert "''" in url_str or "%27%27" in url_str + # CRLF characters are URL-encoded by HTTP layer + assert "DisplayName" in url_str